diff --git a/src/app-server/query/cache.ts b/src/app-server/query/cache.ts index cdd60191..a9f7ac24 100644 --- a/src/app-server/query/cache.ts +++ b/src/app-server/query/cache.ts @@ -336,11 +336,11 @@ export class AppServerQueryCache { }> { try { const data = cloneModelMetadata(await this.client.fetchQuery(this.modelsQueryOptionsWithClient(context, client))); - return { data, probe: diagnosticProbeOk("model/list", `${String(data.length)} models`) }; + return { data, probe: diagnosticProbeOk("model/list", `${String(data.length)} models`, Date.now()) }; } catch (error) { return { data: this.modelsSnapshot(context) ?? [], - probe: diagnosticProbeError("model/list", error), + probe: diagnosticProbeError("model/list", error, Date.now()), }; } } diff --git a/src/app-server/query/metadata-probes.ts b/src/app-server/query/metadata-probes.ts index e09084c2..8e6d6aa9 100644 --- a/src/app-server/query/metadata-probes.ts +++ b/src/app-server/query/metadata-probes.ts @@ -21,9 +21,9 @@ export async function readSkillMetadataProbe( if (!client) return disconnectedSkillsResult(); try { const catalog = await listSkillCatalog(client, vaultPath, { forceReload }); - return { data: catalog.skills, probe: diagnosticProbeOk("skills/list", `${String(catalog.totalCount)} skills`) }; + return { data: catalog.skills, probe: diagnosticProbeOk("skills/list", `${String(catalog.totalCount)} skills`, Date.now()) }; } catch (error) { - return { data: [], probe: diagnosticProbeError("skills/list", error) }; + return { data: [], probe: diagnosticProbeError("skills/list", error, Date.now()) }; } } @@ -33,20 +33,20 @@ export async function readRateLimitMetadataProbe(client: AppServerClient | null) const response = await client.readAccountRateLimits(); return { data: rateLimitSnapshotFromAccountRateLimitsResponse(response), - probe: diagnosticProbeOk("account/rateLimits/read", accountRateLimitsSummaryFromResponse(response)), + probe: diagnosticProbeOk("account/rateLimits/read", accountRateLimitsSummaryFromResponse(response), Date.now()), }; } catch (error) { - return { data: null, probe: diagnosticProbeError("account/rateLimits/read", error) }; + return { data: null, probe: diagnosticProbeError("account/rateLimits/read", error, Date.now()) }; } } function disconnectedSkillsResult(): SkillMetadataProbeResult { - return { data: [], probe: diagnosticProbeError("skills/list", new Error("Codex app-server is not connected.")) }; + return { data: [], probe: diagnosticProbeError("skills/list", new Error("Codex app-server is not connected."), Date.now()) }; } function disconnectedRateLimitResult(): RateLimitMetadataProbeResult { return { data: null, - probe: diagnosticProbeError("account/rateLimits/read", new Error("Codex app-server is not connected.")), + probe: diagnosticProbeError("account/rateLimits/read", new Error("Codex app-server is not connected."), Date.now()), }; } diff --git a/src/domain/server/diagnostics.ts b/src/domain/server/diagnostics.ts index 44071743..62855609 100644 --- a/src/domain/server/diagnostics.ts +++ b/src/domain/server/diagnostics.ts @@ -89,11 +89,7 @@ function createDiagnosticProbeResult(method: DiagnosticProbeMethod): DiagnosticP }; } -export function diagnosticProbeOk( - method: DiagnosticProbeMethod, - summary: string | null = null, - checkedAt = Date.now(), -): DiagnosticProbeResult { +export function diagnosticProbeOk(method: DiagnosticProbeMethod, summary: string | null, checkedAt: number): DiagnosticProbeResult { return { method, status: "ok", @@ -103,7 +99,7 @@ export function diagnosticProbeOk( }; } -export function diagnosticProbeError(method: DiagnosticProbeMethod, error: unknown, checkedAt = Date.now()): DiagnosticProbeResult { +export function diagnosticProbeError(method: DiagnosticProbeMethod, error: unknown, checkedAt: number): DiagnosticProbeResult { return { method, status: "failed", diff --git a/src/features/chat/app-server/actions/diagnostics.ts b/src/features/chat/app-server/actions/diagnostics.ts index f076943b..3bcd997a 100644 --- a/src/features/chat/app-server/actions/diagnostics.ts +++ b/src/features/chat/app-server/actions/diagnostics.ts @@ -187,11 +187,11 @@ async function probeDiagnostic( const statuses = mcpServerStatuses?.(response); return { method, - probe: diagnosticProbeOk(method, summarize(response)), + probe: diagnosticProbeOk(method, summarize(response), Date.now()), ...(statuses ? { mcpServerStatuses: statuses } : {}), }; } catch (error) { - return { method, probe: diagnosticProbeError(method, error) }; + return { method, probe: diagnosticProbeError(method, error, Date.now()) }; } } diff --git a/src/features/chat/app-server/inbound/controller.ts b/src/features/chat/app-server/inbound/controller.ts index 35ca0d19..ba1dc683 100644 --- a/src/features/chat/app-server/inbound/controller.ts +++ b/src/features/chat/app-server/inbound/controller.ts @@ -2,7 +2,7 @@ import type { RequestId, ServerNotification, ServerRequest } from "../../../../a import type { McpServerStartupStatus } from "../../../../domain/server/diagnostics"; import type { Thread } from "../../../../domain/threads/model"; import type { ThreadConversationSummary } from "../../../../domain/threads/transcript"; -import { createLocalIdSource, type LocalIdSource } from "../../../../shared/id/local-id"; +import type { LocalIdSource } from "../../../../shared/id/local-id"; import { classifyAppServerLog } from "./app-server-logs"; import { activeTurnId, type ChatAction, type ChatState } from "../../application/state/root-reducer"; import type { ChatStateStore } from "../../application/state/store"; @@ -51,11 +51,10 @@ export interface ChatInboundControllerActions { } export class ChatInboundController { - private readonly localItemIds: LocalIdSource = createLocalIdSource(); - constructor( private readonly store: ChatStateStore, private readonly actions: ChatInboundControllerActions, + private readonly localItemIds: LocalIdSource, ) {} private get state(): ChatState { diff --git a/src/features/chat/application/conversation/composition.ts b/src/features/chat/application/conversation/composition.ts index 0aca8124..78525d98 100644 --- a/src/features/chat/application/conversation/composition.ts +++ b/src/features/chat/application/conversation/composition.ts @@ -1,6 +1,7 @@ import type { AppServerClient } from "../../../../app-server/connection/client"; import type { CodexInput } from "../../../../domain/chat/input"; import type { MessageStreamNoticeSection } from "../../domain/message-stream/items"; +import type { LocalIdSource } from "../../../../shared/id/local-id"; import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions"; import { activeThreadId, canImplementPlanItemId } from "../state/selectors"; import type { ChatStateStore } from "../state/store"; @@ -15,6 +16,7 @@ const IMPLEMENT_PLAN_PROMPT = "Please implement this plan."; export interface ConversationTurnActionsContext { vaultPath: string; stateStore: ChatStateStore; + localItemIds: LocalIdSource; client: { currentClient: () => AppServerClient | null; ensureConnected: () => Promise; @@ -81,10 +83,11 @@ export function createConversationTurnActions( context: ConversationTurnActionsContext, refs: ConversationTurnActionsRefs, ): ConversationTurnActions { - const { vaultPath, stateStore, client, status, runtime, thread, composer, scroll } = context; + const { vaultPath, stateStore, localItemIds, client, status, runtime, thread, composer, scroll } = context; const turnSubmission = createTurnSubmissionActions({ stateStore, vaultPath, + localItemIds, currentClient: client.currentClient, ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded, startThread: (preview) => refs.threadStarter.startThread(preview), diff --git a/src/features/chat/application/conversation/turn-submission-actions.ts b/src/features/chat/application/conversation/turn-submission-actions.ts index 3fed2385..63e297f4 100644 --- a/src/features/chat/application/conversation/turn-submission-actions.ts +++ b/src/features/chat/application/conversation/turn-submission-actions.ts @@ -1,7 +1,7 @@ import type { AppServerClient } from "../../../../app-server/connection/client"; import type { CodexInput } from "../../../../domain/chat/input"; import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference"; -import { createLocalIdSource, type LocalIdSource } from "../../../../shared/id/local-id"; +import type { LocalIdSource } from "../../../../shared/id/local-id"; import { submissionStateSnapshot } from "../state/selectors"; import type { ChatStateStore } from "../state/store"; import { @@ -18,6 +18,7 @@ const STATUS_STEERED_CURRENT_TURN = "Steered current turn."; export interface TurnSubmissionActionsHost { stateStore: ChatStateStore; vaultPath: string; + localItemIds: LocalIdSource; currentClient: () => AppServerClient | null; ensureRestoredThreadLoaded: () => Promise; startThread: (preview?: string) => Promise; @@ -30,6 +31,14 @@ export interface TurnSubmissionActionsHost { addSystemMessage: (text: string) => void; } +type TurnSubmissionSnapshot = ReturnType; + +type TurnSubmissionPlan = + | { kind: "blocked"; message: string } + | { kind: "steer"; threadId: string; turnId: string } + | { kind: "start-thread-then-turn" } + | { kind: "start-turn"; threadId: string }; + function currentTurnNotSteerableMessage(): string { return "Current turn is not steerable yet."; } @@ -39,11 +48,9 @@ export interface TurnSubmissionActions { } export function createTurnSubmissionActions(host: TurnSubmissionActionsHost): TurnSubmissionActions { - const localItemIds = createLocalIdSource(); - return { sendTurnText: (text, codexInputOverride, referencedThread) => - sendTurnText(host, localItemIds, text, codexInputOverride, referencedThread), + sendTurnText(host, host.localItemIds, text, codexInputOverride, referencedThread), }; } @@ -59,20 +66,24 @@ async function sendTurnText( if (!client) return; const initialState = submissionStateSnapshot(host.stateStore.getState()); - if (initialState.busy) { - await steerCurrentTurn(host, localItemIds, client, text, codexInputOverride, referencedThread); - return; - } + const plan = planTurnSubmission(initialState); let optimisticUserId: string | null = null; try { - if (!initialState.activeThreadId) { - const threadResponse = await host.startThread(text); - if (!threadResponse) return; - host.notifyActiveThreadIdentityChanged(); - host.resetThreadTurnPresence(false); + switch (plan.kind) { + case "blocked": + host.addSystemMessage(plan.message); + return; + case "steer": + await steerCurrentTurn(host, localItemIds, client, plan, text, codexInputOverride, referencedThread); + return; + case "start-thread-then-turn": + if (!(await startThreadForTurn(host, text))) return; + break; + case "start-turn": + break; } - const activeThreadId = submissionStateSnapshot(host.stateStore.getState()).activeThreadId; + const activeThreadId = plan.kind === "start-turn" ? plan.threadId : submissionStateSnapshot(host.stateStore.getState()).activeThreadId; if (!activeThreadId) return; if (!(await host.applyPendingThreadSettings())) return; @@ -133,42 +144,52 @@ async function sendTurnText( } } +function planTurnSubmission(state: TurnSubmissionSnapshot): TurnSubmissionPlan { + if (state.busy) { + return state.activeThreadId && state.activeTurnId + ? { kind: "steer", threadId: state.activeThreadId, turnId: state.activeTurnId } + : { kind: "blocked", message: currentTurnNotSteerableMessage() }; + } + return state.activeThreadId ? { kind: "start-turn", threadId: state.activeThreadId } : { kind: "start-thread-then-turn" }; +} + +async function startThreadForTurn(host: TurnSubmissionActionsHost, text: string): Promise { + const threadResponse = await host.startThread(text); + if (!threadResponse) return false; + host.notifyActiveThreadIdentityChanged(); + host.resetThreadTurnPresence(false); + return true; +} + async function steerCurrentTurn( host: TurnSubmissionActionsHost, localItemIds: LocalIdSource, client: AppServerClient, + plan: Extract, text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadMetadata, ): Promise { - const state = submissionStateSnapshot(host.stateStore.getState()); - const threadId = state.activeThreadId; - const expectedTurnId = state.activeTurnId; - if (!threadId || !expectedTurnId) { - host.addSystemMessage(currentTurnNotSteerableMessage()); - return; - } - const codexInput = codexInputOverride ?? host.codexInput(text); const localSteerId = localItemIds.next("local-steer"); host.setDraft("", { clearSuggestions: true }); try { - await client.steerTurn(threadId, expectedTurnId, codexInput, localSteerId); - if (!isCurrentTurn(host, threadId, expectedTurnId)) return; + await client.steerTurn(plan.threadId, plan.turnId, codexInput, localSteerId); + if (!isCurrentTurn(host, plan.threadId, plan.turnId)) return; host.stateStore.dispatch({ type: "message-stream/item-added", item: localUserMessageItemFromInput({ id: localSteerId, text, - turnId: expectedTurnId, + turnId: plan.turnId, referencedThread, codexInput, }), }); host.setStatus(STATUS_STEERED_CURRENT_TURN); } catch (error) { - if (!isCurrentTurn(host, threadId, expectedTurnId)) return; + if (!isCurrentTurn(host, plan.threadId, plan.turnId)) return; host.setDraft(text, { focus: true }); host.addSystemMessage(error instanceof Error ? error.message : String(error)); } diff --git a/src/features/chat/application/threads/goal-actions.ts b/src/features/chat/application/threads/goal-actions.ts index 43402ba8..9ac5e329 100644 --- a/src/features/chat/application/threads/goal-actions.ts +++ b/src/features/chat/application/threads/goal-actions.ts @@ -1,7 +1,7 @@ import type { AppServerClient } from "../../../../app-server/connection/client"; import { readThreadGoal, recordThreadGoalUserMessage, setThreadGoal } from "../../../../app-server/threads"; import type { ThreadGoal, ThreadGoalStatus, ThreadGoalUpdate } from "../../../../domain/threads/goal"; -import { createLocalIdSource, type LocalIdSource } from "../../../../shared/id/local-id"; +import type { LocalIdSource } from "../../../../shared/id/local-id"; import type { ChatStateStore } from "../state/store"; import type { GoalMessageStreamItem } from "../../domain/message-stream/items"; import { goalChangeItem } from "../../domain/message-stream/factories/goal-items"; @@ -9,6 +9,7 @@ import { goalChangeItem } from "../../domain/message-stream/factories/goal-items export interface ThreadGoalSyncHost { stateStore: ChatStateStore; currentClient: () => AppServerClient | null; + localItemIds: LocalIdSource; addSystemMessage: (text: string) => void; addGoalEvent: (item: GoalMessageStreamItem) => void; refreshLiveState: () => void; @@ -16,6 +17,7 @@ export interface ThreadGoalSyncHost { export interface GoalActionsHost extends ThreadGoalSyncHost { ensureConnected: () => Promise; + startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<{ threadId: string } | null>; } export interface ThreadGoalSyncActions { @@ -24,79 +26,111 @@ export interface ThreadGoalSyncActions { export interface GoalActions extends ThreadGoalSyncActions { activeGoal: () => ThreadGoal | null; + saveObjective: (objective: string, tokenBudget: number | null) => Promise; setObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise; setStatus: (threadId: string, status: ThreadGoalStatus) => Promise; clear: (threadId: string) => Promise; + startEditingCurrent: () => void; + startEditing: (threadId: string | null, objective: string, tokenBudget: number | null) => void; + updateObjectiveDraft: (objective: string) => void; + setObjectiveExpanded: (threadId: string, expanded: boolean) => void; + closeEditor: () => void; } +type GoalObjectiveSavePlan = + | { kind: "reject"; message: string } + | { kind: "save-existing"; threadId: string; objective: string; tokenBudget: number | null } + | { kind: "start-thread-and-save"; objective: string; tokenBudget: number | null }; + function emptyGoalObjectiveMessage(): string { return "Goal objective cannot be empty."; } export function createThreadGoalSyncActions(host: ThreadGoalSyncHost): ThreadGoalSyncActions { - const localItemIds = createLocalIdSource(); return { - syncThreadGoal: (threadId) => syncThreadGoal(host, localItemIds, threadId), + syncThreadGoal: (threadId) => syncThreadGoal(host, threadId), }; } export function createGoalActions(host: GoalActionsHost): GoalActions { - const localItemIds = createLocalIdSource(); return { activeGoal: () => host.stateStore.getState().activeThread.goal, - syncThreadGoal: (threadId) => syncThreadGoal(host, localItemIds, threadId), - setObjective: (threadId, objective, tokenBudget) => setObjective(host, localItemIds, threadId, objective, tokenBudget), - setStatus: (threadId, status) => setGoalStatus(host, localItemIds, threadId, status), - clear: (threadId) => clearGoal(host, localItemIds, threadId), + syncThreadGoal: (threadId) => syncThreadGoal(host, threadId), + saveObjective: (objective, tokenBudget) => saveObjective(host, objective, tokenBudget), + setObjective: (threadId, objective, tokenBudget) => setObjective(host, threadId, objective, tokenBudget), + setStatus: (threadId, status) => setGoalStatus(host, threadId, status), + clear: (threadId) => clearGoal(host, threadId), + startEditingCurrent: () => { + startEditingCurrent(host); + }, + startEditing: (threadId, objective, tokenBudget) => { + startEditing(host, threadId, objective, tokenBudget); + }, + updateObjectiveDraft: (objective) => { + host.stateStore.dispatch({ type: "ui/goal-editor-draft-updated", objective }); + }, + setObjectiveExpanded: (threadId, expanded) => { + host.stateStore.dispatch({ type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: threadId, open: expanded }); + }, + closeEditor: () => { + host.stateStore.dispatch({ type: "ui/goal-editor-closed" }); + }, }; } -async function syncThreadGoal(host: ThreadGoalSyncHost, localItemIds: LocalIdSource, threadId: string): Promise { +async function syncThreadGoal(host: ThreadGoalSyncHost, threadId: string): Promise { const client = host.currentClient(); if (!client) return; try { - applyGoalIfActive(host, localItemIds, threadId, await readThreadGoal(client, threadId), { reportChange: false }); + applyGoalIfActive(host, threadId, await readThreadGoal(client, threadId), { reportChange: false }); } catch (error) { addThreadScopedSystemMessage(host, threadId, `Could not load thread goal: ${errorMessage(error)}`); } } -async function setObjective( - host: GoalActionsHost, - localItemIds: LocalIdSource, - threadId: string, - objective: string, - tokenBudget: number | null, -): Promise { - const trimmed = objective.trim(); - if (!trimmed) { +async function setObjective(host: GoalActionsHost, threadId: string, objective: string, tokenBudget: number | null): Promise { + const normalized = normalizedGoalObjective(objective); + if (!normalized) { host.addSystemMessage(emptyGoalObjectiveMessage()); return false; } const current = host.stateStore.getState().activeThread.goal; const isNewGoal = current === null; - const applied = await setGoal(host, localItemIds, threadId, { - objective: trimmed, + const applied = await setGoal(host, threadId, { + objective: normalized, status: current?.status ?? "active", tokenBudget, }); if (applied && isNewGoal) { - await recordGoalUserMessage(host, threadId, trimmed); + await recordGoalUserMessage(host, threadId, normalized); } return applied; } -function setGoalStatus(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string, status: ThreadGoalStatus): Promise { - return setGoal(host, localItemIds, threadId, { status }); +async function saveObjective(host: GoalActionsHost, objective: string, tokenBudget: number | null): Promise { + const plan = planGoalObjectiveSave(host.stateStore.getState().activeThread.id, objective, tokenBudget); + switch (plan.kind) { + case "reject": + host.addSystemMessage(plan.message); + return false; + case "save-existing": + return setObjective(host, plan.threadId, plan.objective, plan.tokenBudget); + case "start-thread-and-save": + return startThreadAndSaveObjective(host, plan); + } } -async function clearGoal(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string): Promise { +function setGoalStatus(host: GoalActionsHost, threadId: string, status: ThreadGoalStatus): Promise { + return setGoal(host, threadId, { status }); +} + +async function clearGoal(host: GoalActionsHost, threadId: string): Promise { await host.ensureConnected(); const client = host.currentClient(); if (!client) return false; try { await client.clearThreadGoal(threadId); - applyGoalIfActive(host, localItemIds, threadId, null, { reportChange: true }); + applyGoalIfActive(host, threadId, null, { reportChange: true }); return true; } catch (error) { addThreadScopedSystemMessage(host, threadId, errorMessage(error)); @@ -104,12 +138,12 @@ async function clearGoal(host: GoalActionsHost, localItemIds: LocalIdSource, thr } } -async function setGoal(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string, params: ThreadGoalUpdate): Promise { +async function setGoal(host: GoalActionsHost, threadId: string, params: ThreadGoalUpdate): Promise { await host.ensureConnected(); const client = host.currentClient(); if (!client) return false; try { - return applyGoalIfActive(host, localItemIds, threadId, await setThreadGoal(client, threadId, params), { reportChange: true }); + return applyGoalIfActive(host, threadId, await setThreadGoal(client, threadId, params), { reportChange: true }); } catch (error) { addThreadScopedSystemMessage(host, threadId, errorMessage(error)); return false; @@ -118,20 +152,57 @@ async function setGoal(host: GoalActionsHost, localItemIds: LocalIdSource, threa function applyGoalIfActive( host: ThreadGoalSyncHost, - localItemIds: LocalIdSource, threadId: string, goal: ThreadGoal | null, options: { reportChange: boolean }, ): boolean { const state = host.stateStore.getState(); if (state.activeThread.id !== threadId) return false; - const item = options.reportChange ? goalChangeItem(localItemIds.next("goal"), state.activeThread.goal, goal) : null; + const item = options.reportChange ? goalChangeItem(host.localItemIds.next("goal"), state.activeThread.goal, goal) : null; host.stateStore.dispatch({ type: "active-thread/goal-set", goal }); if (item) host.addGoalEvent(item); host.refreshLiveState(); return true; } +function startEditingCurrent(host: GoalActionsHost): void { + host.stateStore.dispatch({ type: "ui/panel-set", panel: null }); + const goal = host.stateStore.getState().activeThread.goal; + startEditing(host, goal?.threadId ?? null, goal?.objective ?? "", goal?.tokenBudget ?? null); +} + +function startEditing(host: GoalActionsHost, threadId: string | null, objective: string, tokenBudget: number | null): void { + host.stateStore.dispatch({ type: "ui/goal-editor-started", threadId, objective, tokenBudget }); +} + +function planGoalObjectiveSave(activeThreadId: string | null, objective: string, tokenBudget: number | null): GoalObjectiveSavePlan { + const normalized = normalizedGoalObjective(objective); + if (!normalized) return { kind: "reject", message: emptyGoalObjectiveMessage() }; + return activeThreadId + ? { kind: "save-existing", threadId: activeThreadId, objective: normalized, tokenBudget } + : { kind: "start-thread-and-save", objective: normalized, tokenBudget }; +} + +function normalizedGoalObjective(objective: string): string | null { + const trimmed = objective.trim(); + return trimmed || null; +} + +async function startThreadAndSaveObjective( + host: GoalActionsHost, + plan: Extract, +): Promise { + try { + await host.ensureConnected(); + const response = await host.startThread(plan.objective, { syncGoal: false }); + const threadId = response?.threadId ?? null; + return threadId ? await setObjective(host, threadId, plan.objective, plan.tokenBudget) : false; + } catch (error) { + host.addSystemMessage(errorMessage(error)); + return false; + } +} + async function recordGoalUserMessage(host: GoalActionsHost, threadId: string, objective: string): Promise { const client = host.currentClient(); if (!client) return; diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index c81da326..a4f0a028 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -30,6 +30,7 @@ export interface ThreadManagementActionsHost { } export interface ThreadManagementActions { + compactActiveThread: () => Promise; compactThread: (threadId: string) => Promise; archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise; forkThread: (threadId: string) => Promise; @@ -40,6 +41,7 @@ export interface ThreadManagementActions { export function createThreadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions { return { + compactActiveThread: () => compactActiveThread(host), compactThread: (threadId) => compactThread(host, threadId), archiveThread: (threadId, saveMarkdown) => archiveThread(host, threadId, saveMarkdown), forkThread: (threadId) => forkThread(host, threadId), @@ -81,10 +83,23 @@ function noCompletedTurnToRollbackMessage(): string { return "No completed turn to roll back."; } +function noActiveThreadToCompactMessage(): string { + return "No active thread to compact."; +} + function rollbackCompletedMessage(): string { return "Rolled back the latest turn. Local file changes were not reverted."; } +async function compactActiveThread(host: ThreadManagementActionsHost): Promise { + const threadId = threadManagementState(host).activeThread.id; + if (!threadId) { + host.addSystemMessage(noActiveThreadToCompactMessage()); + return; + } + await compactThread(host, threadId); +} + async function compactThread(host: ThreadManagementActionsHost, threadId: string): Promise { await host.ensureConnected(); const client = host.currentClient(); diff --git a/src/features/chat/host/connection-bundle.ts b/src/features/chat/host/connection-bundle.ts index 23d498ad..002a2651 100644 --- a/src/features/chat/host/connection-bundle.ts +++ b/src/features/chat/host/connection-bundle.ts @@ -4,6 +4,7 @@ import type { AppServerClient } from "../../../app-server/connection/client"; import type { ConnectionManager } from "../../../app-server/connection/connection-manager"; import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries"; import type { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work"; +import type { LocalIdSource } from "../../../shared/id/local-id"; import { createChatConnectionController, handleChatConnectionExit, @@ -35,6 +36,7 @@ interface ChatPanelConnectionStatus { interface ChatPanelConnectionBundleInput { connection: ConnectionManager; currentClient: CurrentAppServerClient; + localItemIds: LocalIdSource; status: ChatPanelConnectionStatus; goalSync: ChatPanelGoalSyncActions; autoTitle: AutoTitleActions; @@ -95,7 +97,7 @@ export function createConnectionBundle( input: ChatPanelConnectionBundleInput, ): ChatPanelConnectionBundle { const { environment, stateStore } = host; - const { connection, currentClient, status, goalSync, autoTitle } = input; + const { connection, currentClient, localItemIds, status, goalSync, autoTitle } = input; const serverMetadata = createChatServerMetadataActions({ stateStore, vaultPath: environment.plugin.settingsRef.vaultPath, @@ -133,38 +135,42 @@ export function createConnectionBundle( status.addSystemMessage(error instanceof Error ? error.message : String(error)); }); }; - const inboundController = new ChatInboundController(stateStore, { - refreshActiveThreads: () => { - refreshSharedThreadsQuietly(); + const inboundController = new ChatInboundController( + stateStore, + { + refreshActiveThreads: () => { + refreshSharedThreadsQuietly(); + }, + refreshRateLimits: () => { + void serverMetadata.refreshRateLimits({ preserveExistingOnFailure: true }); + }, + refreshSkills: (forceReload) => void serverMetadata.refreshSkills(forceReload), + applyAppServerMetadataSnapshot: () => { + serverMetadata.applyAppServerMetadataSnapshot(); + }, + maybeNameThread: (threadId, turnId, completedSummary) => { + autoTitle.maybeAutoTitleThread(threadId, turnId, completedSummary); + }, + upsertActiveThread: (thread) => { + environment.plugin.threadCatalog.upsertFromAppServer(thread); + }, + applyThreadArchived: (threadId) => { + environment.plugin.threadCatalog.recordThreadArchived(threadId); + }, + recordActiveThreadDeleted: (threadId) => { + environment.plugin.threadCatalog.recordThreadDeleted(threadId); + }, + applyThreadRenamed: (threadId, name) => { + environment.plugin.threadCatalog.recordThreadRenamed(threadId, name); + }, + recordMcpStartupStatus: (name, mcpStatus, message) => { + serverDiagnostics.recordMcpStartupStatus(name, mcpStatus, message); + }, + respondToServerRequest: (requestId, result) => respondToCurrentServerRequest(currentClient, requestId, result), + rejectServerRequest: (requestId, code, message) => rejectCurrentServerRequest(currentClient, requestId, code, message), }, - refreshRateLimits: () => { - void serverMetadata.refreshRateLimits({ preserveExistingOnFailure: true }); - }, - refreshSkills: (forceReload) => void serverMetadata.refreshSkills(forceReload), - applyAppServerMetadataSnapshot: () => { - serverMetadata.applyAppServerMetadataSnapshot(); - }, - maybeNameThread: (threadId, turnId, completedSummary) => { - autoTitle.maybeAutoTitleThread(threadId, turnId, completedSummary); - }, - upsertActiveThread: (thread) => { - environment.plugin.threadCatalog.upsertFromAppServer(thread); - }, - applyThreadArchived: (threadId) => { - environment.plugin.threadCatalog.recordThreadArchived(threadId); - }, - recordActiveThreadDeleted: (threadId) => { - environment.plugin.threadCatalog.recordThreadDeleted(threadId); - }, - applyThreadRenamed: (threadId, name) => { - environment.plugin.threadCatalog.recordThreadRenamed(threadId, name); - }, - recordMcpStartupStatus: (name, mcpStatus, message) => { - serverDiagnostics.recordMcpStartupStatus(name, mcpStatus, message); - }, - respondToServerRequest: (requestId, result) => respondToCurrentServerRequest(currentClient, requestId, result), - rejectServerRequest: (requestId, code, message) => rejectCurrentServerRequest(currentClient, requestId, code, message), - }); + localItemIds, + ); const connectionExitHost = { stateStore, connectionWork: host.connectionWork, diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index fd5c36eb..90755d3d 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -177,7 +177,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch host.resumeWork.invalidate(); history.invalidate(); }; - const goalSync = createSessionGoalSyncActions(host, currentClient, status); + const goalSync = createSessionGoalSyncActions(host, currentClient, localItemIds, status); const serverParts = createConnectionBundle( { environment, @@ -198,6 +198,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch { connection, currentClient, + localItemIds, goalSync, autoTitle, status, @@ -213,7 +214,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch const refreshActiveThreads = () => connectionController.refreshActiveThreads(); const threadOperations = createSessionThreadOperations(environment, currentClient); const runtimeSettings = createSessionRuntimeSettingsActions(host, currentClient, status); - const goals = createSessionGoalActions(host, currentClient, ensureConnected, status); + const goals = createSessionGoalActions(host, currentClient, localItemIds, ensureConnected, status, serverThreads); const rename = createSessionThreadRenameEditorActions(stateStore, threadOperations, titleService, ensureConnected, status); const threadLifecycle = createSessionThreadLifecycle( host, @@ -249,6 +250,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch }); const composerAndTurn = createComposerAndTurnActions(host, { connection, + localItemIds, ensureConnected, currentClient, status, @@ -274,8 +276,6 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch const surfaceAndPresenter = createSurfacesAndPresenter(host, { connection, connectionController, - inboundController, - serverThreads, goals, rename, threadActions: threadActionParts.actions, @@ -471,11 +471,13 @@ function createSessionHistoryController( function createSessionGoalSyncActions( host: ChatPanelSessionGraphHost, currentClient: CurrentAppServerClient, + localItemIds: LocalIdSource, status: ChatPanelSessionStatus, ): ChatPanelGoalSyncActions { return createThreadGoalSyncActions({ stateStore: host.stateStore, currentClient, + localItemIds, addSystemMessage: (text) => { status.addSystemMessage(text); }, @@ -537,13 +539,17 @@ function createSessionRuntimeSettingsActions( function createSessionGoalActions( host: ChatPanelSessionGraphHost, currentClient: CurrentAppServerClient, + localItemIds: LocalIdSource, ensureConnected: () => Promise, status: ChatPanelSessionStatus, + serverThreads: ChatServerThreadActions, ): ChatPanelGoalActions { return createGoalActions({ stateStore: host.stateStore, currentClient, + localItemIds, ensureConnected, + startThread: (preview, options) => serverThreads.startThread(preview, options), addSystemMessage: (text) => { status.addSystemMessage(text); }, @@ -728,6 +734,7 @@ function createComposerAndTurnActions( host: ChatPanelSessionGraphHost, input: { connection: ConnectionManager; + localItemIds: LocalIdSource; ensureConnected: () => Promise; currentClient: CurrentAppServerClient; status: ChatPanelSessionStatus; @@ -753,6 +760,7 @@ function createComposerAndTurnActions( ): ChatPanelComposerAndTurnParts { const { connection, + localItemIds, ensureConnected, currentClient, status, @@ -806,6 +814,7 @@ function createComposerAndTurnActions( { vaultPath: host.environment.plugin.settingsRef.vaultPath, stateStore: host.stateStore, + localItemIds, client: { currentClient, ensureConnected, @@ -864,8 +873,6 @@ function createSurfacesAndPresenter( input: { connection: ConnectionManager; connectionController: ChatPanelConnectionBundle["connection"]["controller"]; - inboundController: ChatInboundController; - serverThreads: ChatServerThreadActions; goals: ChatPanelGoalActions; rename: ThreadRenameEditorActions; threadActions: ChatPanelThreadActions; @@ -882,8 +889,6 @@ function createSurfacesAndPresenter( const { connection, connectionController, - inboundController, - serverThreads, goals, rename, threadActions, @@ -899,14 +904,13 @@ function createSurfacesAndPresenter( const { environment, stateStore } = host; const toolbarActions = createChatPanelToolbarActions( { - stateStore, startNewThread, }, { connectionController, reconnectPanel: reconnect, - inboundController, threadActions, + goals, toolbarPanels, rename, selection, @@ -926,12 +930,8 @@ function createSurfacesAndPresenter( const goalSurface = createChatPanelGoalSurface( { sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut, - stateStore, }, { - connectionController, - inboundController, - threadStarter: serverThreads, goals, }, ); diff --git a/src/features/chat/panel/surface/goal-projection.tsx b/src/features/chat/panel/surface/goal-projection.tsx index 903d3135..7127eb9a 100644 --- a/src/features/chat/panel/surface/goal-projection.tsx +++ b/src/features/chat/panel/surface/goal-projection.tsx @@ -1,17 +1,13 @@ -import type { ChatConnectionController } from "../../application/connection/connection-controller"; -import type { ChatInboundController } from "../../app-server/inbound/controller"; import type { GoalActions } from "../../application/threads/goal-actions"; import type { ComponentChild as UiNode } from "preact"; import { h } from "preact"; -import type { ChatAction, ChatState } from "../../application/state/root-reducer"; -import type { ChatStateStore } from "../../application/state/store"; import type { SendShortcut } from "../../../../shared/ui/keyboard"; import type { GoalPanelActions, GoalPanelDisplayState, GoalPanelEditorState, GoalPanelOptions } from "../../ui/goal"; import { GoalPanel } from "../../ui/goal"; import { goalStateFromShellState, useChatPanelShellState, type ChatPanelGoalShellState } from "../shell-state"; interface ChatPanelGoalActions { - saveObjective: (objective: string, tokenBudget: number | null) => Promise; + saveObjective: (objective: string, tokenBudget: number | null) => Promise; setStatus: (threadId: string, status: "active" | "paused") => Promise; clear: (threadId: string) => Promise; startEditing: (threadId: string | null, objective: string, tokenBudget: number | null) => void; @@ -31,20 +27,12 @@ export interface ChatPanelGoalSurface { export interface ChatPanelGoalSurfaceHost { sendShortcut: () => SendShortcut; - stateStore: ChatStateStore; } export interface ChatPanelGoalSurfaceDependencies { - connectionController: ChatConnectionController; - inboundController: ChatInboundController; - threadStarter: ChatPanelGoalThreadStarter; goals: GoalActions; } -interface ChatPanelGoalThreadStarter { - startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<{ threadId: string } | null>; -} - interface ChatPanelGoalProjection { goal: ChatPanelGoalShellState["activeThread"]["goal"]; goalThreadId: string | null; @@ -53,30 +41,26 @@ interface ChatPanelGoalProjection { } export function createChatPanelGoalSurface(host: ChatPanelGoalSurfaceHost, deps: ChatPanelGoalSurfaceDependencies): ChatPanelGoalSurface { - const dispatch = (action: ChatAction): void => { - host.stateStore.dispatch(action); - }; - return { settings: { sendShortcut: host.sendShortcut, }, actions: { goal: { - saveObjective: (objective, tokenBudget) => saveGoalObjective(host.stateStore.getState(), deps, objective, tokenBudget), + saveObjective: (objective, tokenBudget) => deps.goals.saveObjective(objective, tokenBudget), setStatus: (threadId, status) => deps.goals.setStatus(threadId, status), clear: (threadId) => deps.goals.clear(threadId), startEditing: (threadId, objective, tokenBudget) => { - dispatch({ type: "ui/goal-editor-started", threadId, objective, tokenBudget }); + deps.goals.startEditing(threadId, objective, tokenBudget); }, updateObjectiveDraft: (objective) => { - dispatch({ type: "ui/goal-editor-draft-updated", objective }); + deps.goals.updateObjectiveDraft(objective); }, setObjectiveExpanded: (threadId, expanded) => { - dispatch({ type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: threadId, open: expanded }); + deps.goals.setObjectiveExpanded(threadId, expanded); }, closeEditor: () => { - dispatch({ type: "ui/goal-editor-closed" }); + deps.goals.closeEditor(); }, }, }, @@ -88,27 +72,6 @@ export function ChatPanelGoal({ surface }: { surface: ChatPanelGoalSurface }): U return h(GoalPanel, props); } -async function saveGoalObjective( - state: ChatState, - deps: ChatPanelGoalSurfaceDependencies, - objective: string, - tokenBudget: number | null, -): Promise { - let threadId = state.activeThread.id; - if (!threadId) { - try { - await deps.connectionController.ensureConnected(); - const response = await deps.threadStarter.startThread(objective, { syncGoal: false }); - threadId = response?.threadId ?? null; - } catch (error) { - deps.inboundController.addSystemMessage(error instanceof Error ? error.message : String(error)); - return; - } - } - if (!threadId) return; - void deps.goals.setObjective(threadId, objective, tokenBudget); -} - function chatPanelGoalProjection(state: ChatPanelGoalShellState): ChatPanelGoalProjection { const goal = state.activeThread.goal; const goalThreadId = goal?.threadId ?? null; @@ -142,8 +105,9 @@ function chatPanelGoalViewModel( goal: projection.goal, actions: { onSave: (objective, tokenBudget) => { - void surface.actions.goal.saveObjective(objective, tokenBudget); - surface.actions.goal.closeEditor(); + void surface.actions.goal.saveObjective(objective, tokenBudget).then((saved) => { + if (saved) surface.actions.goal.closeEditor(); + }); }, onPause: () => { if (!projection.goalThreadId) return; diff --git a/src/features/chat/panel/toolbar-actions.ts b/src/features/chat/panel/toolbar-actions.ts index fb8a035c..a324c592 100644 --- a/src/features/chat/panel/toolbar-actions.ts +++ b/src/features/chat/panel/toolbar-actions.ts @@ -2,9 +2,9 @@ import type { ThreadManagementActions } from "../application/threads/thread-mana import type { ChatAction, ChatState } from "../application/state/root-reducer"; import type { ChatStateStore } from "../application/state/store"; import type { ChatConnectionController } from "../application/connection/connection-controller"; +import type { GoalActions } from "../application/threads/goal-actions"; import type { ThreadRenameEditorActions } from "../application/threads/rename-editor-actions"; import type { SelectionActions } from "../application/threads/selection-actions"; -import type { ChatInboundController } from "../app-server/inbound/controller"; import type { ToolbarActions } from "../ui/toolbar"; export interface ToolbarPanelActionsHost { @@ -25,15 +25,14 @@ export interface ToolbarPanelActions { } export interface ChatPanelToolbarActionsHost { - stateStore: ChatStateStore; startNewThread: () => Promise; } export interface ChatPanelToolbarActionDependencies { connectionController: ChatConnectionController; reconnectPanel: () => Promise; - inboundController: ChatInboundController; threadActions: ThreadManagementActions; + goals: GoalActions; toolbarPanels: ToolbarPanelActions; rename: ThreadRenameEditorActions; selection: SelectionActions; @@ -133,17 +132,10 @@ export function createChatPanelToolbarActions(host: ChatPanelToolbarActionsHost, deps.toolbarPanels.toggleChatActions(); }, compactConversation: () => { - void compactConversation(host.stateStore.getState(), deps); + void deps.threadActions.compactActiveThread(); }, setGoal: () => { - host.stateStore.dispatch({ type: "ui/panel-set", panel: null }); - const goal = host.stateStore.getState().activeThread.goal; - host.stateStore.dispatch({ - type: "ui/goal-editor-started", - threadId: goal?.threadId ?? null, - objective: goal?.objective ?? "", - tokenBudget: goal?.tokenBudget ?? null, - }); + deps.goals.startEditingCurrent(); }, toggleHistory: () => { deps.toolbarPanels.toggleHistory(); @@ -184,18 +176,6 @@ export function createChatPanelToolbarActions(host: ChatPanelToolbarActionsHost, }; } -async function compactConversation( - state: ReturnType, - deps: Pick, -): Promise { - const threadId = state.activeThread.id; - if (!threadId) { - deps.inboundController.addSystemMessage("No active thread to compact."); - return; - } - await deps.threadActions.compactThread(threadId); -} - function isToolbarElement(target: EventTarget | null, viewWindow: ToolbarDomWindow | null): target is Element { return Boolean(viewWindow && target instanceof viewWindow.Element); } diff --git a/tests/app-server/app-server-diagnostics.test.ts b/tests/app-server/app-server-diagnostics.test.ts index b7eed743..d30e514a 100644 --- a/tests/app-server/app-server-diagnostics.test.ts +++ b/tests/app-server/app-server-diagnostics.test.ts @@ -57,8 +57,8 @@ describe("app-server diagnostics", () => { }); it("shortens error messages and tracks MCP server diagnostics", () => { - expect(diagnosticProbeError("model/list", "a\n b\t c").message).toBe("a b c"); - expect(diagnosticProbeError("model/list", "x".repeat(200)).message).toHaveLength(160); + expect(diagnosticProbeError("model/list", "a\n b\t c", 1).message).toBe("a b c"); + expect(diagnosticProbeError("model/list", "x".repeat(200), 1).message).toHaveLength(160); let diagnostics = upsertMcpServerDiagnostic(createServerDiagnostics(), { name: "github", diff --git a/tests/app-server/query-cache.test.ts b/tests/app-server/query-cache.test.ts index 1c630d29..1e88ca8a 100644 --- a/tests/app-server/query-cache.test.ts +++ b/tests/app-server/query-cache.test.ts @@ -385,20 +385,20 @@ function metadata( diagnostics = diagnosticsWithProbe( diagnostics, overrides.modelProbeStatus === "failed" - ? diagnosticProbeError("model/list", new Error("offline")) - : diagnosticProbeOk("model/list", "1 models"), + ? diagnosticProbeError("model/list", new Error("offline"), 1) + : diagnosticProbeOk("model/list", "1 models", 1), ); diagnostics = diagnosticsWithProbe( diagnostics, overrides.skillsProbeStatus === "failed" - ? diagnosticProbeError("skills/list", new Error("offline")) - : diagnosticProbeOk("skills/list", "0 skills"), + ? diagnosticProbeError("skills/list", new Error("offline"), 1) + : diagnosticProbeOk("skills/list", "0 skills", 1), ); diagnostics = diagnosticsWithProbe( diagnostics, overrides.rateLimitProbeStatus === "failed" - ? diagnosticProbeError("account/rateLimits/read", new Error("offline")) - : diagnosticProbeOk("account/rateLimits/read", "available"), + ? diagnosticProbeError("account/rateLimits/read", new Error("offline"), 1) + : diagnosticProbeOk("account/rateLimits/read", "available", 1), ); return { runtimeConfig: overrides.runtimeConfig ?? emptyRuntimeConfigSnapshot(), diff --git a/tests/app-server/shared-queries.test.ts b/tests/app-server/shared-queries.test.ts index 56c37910..3770e6b9 100644 --- a/tests/app-server/shared-queries.test.ts +++ b/tests/app-server/shared-queries.test.ts @@ -193,7 +193,7 @@ function model(modelId: string): ModelMetadata { } function serverMetadata(overrides: Partial = {}): SharedServerMetadata { - const diagnostics = diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("model/list", "0 models")); + const diagnostics = diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("model/list", "0 models", 1)); return { runtimeConfig: null, availableModels: [], diff --git a/tests/features/chat/connection/server-actions/server-actions.test.ts b/tests/features/chat/connection/server-actions/server-actions.test.ts index 178aec5d..99db5f4d 100644 --- a/tests/features/chat/connection/server-actions/server-actions.test.ts +++ b/tests/features/chat/connection/server-actions/server-actions.test.ts @@ -258,10 +258,10 @@ describe("chat server actions", () => { rateLimit: rateLimitFixture(), serverDiagnostics: diagnosticsWithProbe( diagnosticsWithProbe( - diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("model/list", "1 models")), - diagnosticProbeOk("skills/list", "1 skills"), + diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("model/list", "1 models", 1)), + diagnosticProbeOk("skills/list", "1 skills", 1), ), - diagnosticProbeOk("account/rateLimits/read", "available"), + diagnosticProbeOk("account/rateLimits/read", "available", 1), ), }); const refreshAppServerMetadata = vi.fn<() => Promise>().mockResolvedValue(refreshedMetadata); @@ -333,7 +333,7 @@ describe("chat server actions", () => { const stateStore = createChatStateStore(chatStateFixture()); const metadataCache = metadataCacheHost({ current: serverMetadataFixture({ - serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("model/list", "cached models")), + serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("model/list", "cached models", 1)), }), }); const listModels = vi.fn().mockResolvedValue({ data: [modelFixture("gpt-direct")] }); @@ -456,7 +456,7 @@ describe("chat server actions", () => { const stateStore = createChatStateStore(state); const metadata = serverMetadataFixture({ availableModels: modelMetadataFromCatalogModels([modelFixture("gpt-cached")]), - serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeError("model/list", new Error("offline"))), + serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeError("model/list", new Error("offline"), 1)), }); const controller = createChatServerMetadataActions({ stateStore, @@ -478,7 +478,7 @@ describe("chat server actions", () => { const stateStore = createChatStateStore(state); const metadata = serverMetadataFixture({ availableModels: [], - serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeError("model/list", new Error("offline"))), + serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeError("model/list", new Error("offline"), 1)), }); const controller = createChatServerMetadataActions({ stateStore, diff --git a/tests/features/chat/conversation/turns/turn-submission-actions.test.ts b/tests/features/chat/conversation/turns/turn-submission-actions.test.ts index 2713635a..80481c6d 100644 --- a/tests/features/chat/conversation/turns/turn-submission-actions.test.ts +++ b/tests/features/chat/conversation/turns/turn-submission-actions.test.ts @@ -8,7 +8,9 @@ import { createTurnSubmissionActions, type TurnSubmissionActionsHost, } from "../../../../../src/features/chat/application/conversation/turn-submission-actions"; +import { optimisticTurnStart } from "../../../../../src/features/chat/application/conversation/optimistic-turn-start"; import type { Thread } from "../../../../../src/domain/threads/model"; +import { createLocalIdSource } from "../../../../../src/shared/id/local-id"; import { chatStateMessageStreamItems } from "../../support/message-stream"; const textInput = (text: string): CodexInput => [{ type: "text", text }]; @@ -61,6 +63,7 @@ function createHost(overrides: TurnSubmissionHostOverrides = {}) { setStatus: vi.fn(), addSystemMessage: vi.fn(), ...overrides, + localItemIds: overrides.localItemIds ?? createLocalIdSource(), }; return { host, startTurn, stateStore, steerTurn }; } @@ -164,6 +167,34 @@ describe("TurnSubmissionActions", () => { ).toBe(true); }); + it("reports busy turns that cannot be steered", async () => { + const { host, startTurn, stateStore, steerTurn } = createHost(); + stateStore.dispatch({ + type: "active-thread/resumed", + thread: thread("thread"), + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalPolicy: null, + approvalsReviewer: null, + activePermissionProfile: null, + }); + const optimistic = optimisticTurnStart({ id: "local-user", text: "pending", codexInput: textInput("pending") }); + stateStore.dispatch({ + type: "turn/optimistic-started", + item: optimistic.item, + pendingTurnStart: optimistic.pendingTurnStart, + }); + const actions = createTurnSubmissionActions(host); + + await actions.sendTurnText("follow up"); + + expect(host.addSystemMessage).toHaveBeenCalledWith("Current turn is not steerable yet."); + expect(steerTurn).not.toHaveBeenCalled(); + expect(startTurn).not.toHaveBeenCalled(); + }); + it("keeps local user ids distinct when submissions share the same timestamp", async () => { const now = vi.spyOn(Date, "now").mockReturnValue(1234); try { diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index b6156afa..7e0954d5 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -280,7 +280,7 @@ function surfaceFixture(options: { toolbarConnected?: () => boolean } = {}): { settings: { sendShortcut: () => "enter" }, actions: { goal: { - saveObjective: async () => undefined, + saveObjective: async () => true, setStatus: async () => undefined, clear: async () => undefined, startEditing: () => undefined, diff --git a/tests/features/chat/panel/surface/projection.test.ts b/tests/features/chat/panel/surface/projection.test.ts index b3f0a975..b630d69b 100644 --- a/tests/features/chat/panel/surface/projection.test.ts +++ b/tests/features/chat/panel/surface/projection.test.ts @@ -299,7 +299,7 @@ describe("chat panel surface projections", () => { }, actions: { goal: { - saveObjective: async () => undefined, + saveObjective: async () => true, setStatus: async (threadId, status) => { statuses.push([threadId, status]); }, @@ -447,7 +447,7 @@ function goalSurfaceFixture(): ChatPanelGoalSurface { }, actions: { goal: { - saveObjective: async () => undefined, + saveObjective: async () => true, setStatus: async () => undefined, clear: async () => undefined, startEditing: () => undefined, diff --git a/tests/features/chat/panel/toolbar-actions.test.ts b/tests/features/chat/panel/toolbar-actions.test.ts index cafd7f4b..349e9733 100644 --- a/tests/features/chat/panel/toolbar-actions.test.ts +++ b/tests/features/chat/panel/toolbar-actions.test.ts @@ -46,65 +46,22 @@ describe("createToolbarPanelActions", () => { }); describe("createChatPanelToolbarActions", () => { - it("reports compact without an active thread through the inbound controller", () => { - const stateStore = createChatStateStore(createChatState()); + it("delegates compacting the active thread", () => { const deps = toolbarActionDeps(); - const actions = createChatPanelToolbarActions({ stateStore, startNewThread: vi.fn() }, deps); + const actions = createChatPanelToolbarActions({ startNewThread: vi.fn() }, deps); actions.compactConversation(); - expect((deps.inboundController.addSystemMessage as unknown as ReturnType).mock.calls).toEqual([ - ["No active thread to compact."], - ]); - expect(vi.mocked(deps.threadActions.compactThread)).not.toHaveBeenCalled(); - }); - - it("compacts the active thread", () => { - const stateStore = createChatStateStore(createChatState()); - stateStore.dispatch({ - type: "active-thread/resumed", - thread: { id: "thread", name: null, preview: "", archived: false, createdAt: 1, updatedAt: 1 }, - cwd: "/vault", - model: null, - reasoningEffort: null, - serviceTier: null, - approvalPolicy: null, - approvalsReviewer: null, - activePermissionProfile: null, - }); - const deps = toolbarActionDeps(); - const actions = createChatPanelToolbarActions({ stateStore, startNewThread: vi.fn() }, deps); - - actions.compactConversation(); - - expect(vi.mocked(deps.threadActions.compactThread)).toHaveBeenCalledWith("thread"); + expect(vi.mocked(deps.threadActions.compactActiveThread)).toHaveBeenCalledOnce(); }); it("starts the goal editor from the active goal", () => { - const stateStore = createChatStateStore(createChatState()); - stateStore.dispatch({ - type: "active-thread/goal-set", - goal: { - threadId: "thread", - objective: "Ship it", - status: "active", - tokenBudget: 5, - tokensUsed: 0, - timeUsedSeconds: 0, - createdAt: 1, - updatedAt: 1, - }, - }); - const actions = createChatPanelToolbarActions({ stateStore, startNewThread: vi.fn() }, toolbarActionDeps()); + const deps = toolbarActionDeps(); + const actions = createChatPanelToolbarActions({ startNewThread: vi.fn() }, deps); actions.setGoal(); - expect(stateStore.getState().ui.goalEditor).toEqual({ - kind: "editing", - threadId: "thread", - objectiveDraft: "Ship it", - tokenBudgetDraft: 5, - }); + expect(vi.mocked(deps.goals.startEditingCurrent)).toHaveBeenCalledOnce(); }); }); @@ -114,11 +71,14 @@ function toolbarActionDeps(): Parameters[1 typeof createChatPanelToolbarActions >[1]["connectionController"], reconnectPanel: vi.fn(), - inboundController: { addSystemMessage: vi.fn() } as unknown as Parameters[1]["inboundController"], threadActions: { archiveThread: vi.fn().mockResolvedValue(undefined), + compactActiveThread: vi.fn().mockResolvedValue(undefined), compactThread: vi.fn().mockResolvedValue(undefined), } as unknown as ThreadManagementActions, + goals: { + startEditingCurrent: vi.fn(), + } as unknown as Parameters[1]["goals"], toolbarPanels: { toggleChatActions: vi.fn(), toggleHistory: vi.fn(), diff --git a/tests/features/chat/panel/toolbar-archive-state.test.tsx b/tests/features/chat/panel/toolbar-archive-state.test.tsx index ba886e56..189150b6 100644 --- a/tests/features/chat/panel/toolbar-archive-state.test.tsx +++ b/tests/features/chat/panel/toolbar-archive-state.test.tsx @@ -162,7 +162,7 @@ function surfaceFixture( settings: { sendShortcut: () => "enter" }, actions: { goal: { - saveObjective: async () => undefined, + saveObjective: async () => true, setStatus: async () => undefined, clear: async () => undefined, startEditing: () => undefined, diff --git a/tests/features/chat/protocol/inbound/controller.test.ts b/tests/features/chat/protocol/inbound/controller.test.ts index 4ce2e3c2..f44a9e17 100644 --- a/tests/features/chat/protocol/inbound/controller.test.ts +++ b/tests/features/chat/protocol/inbound/controller.test.ts @@ -12,6 +12,7 @@ import type { ChatStateStore } from "../../../../../src/features/chat/applicatio import type { ServerNotification, ServerRequest } from "../../../../../src/app-server/connection/rpc-messages"; import type { Thread as PanelThread } from "../../../../../src/domain/threads/model"; import type { TurnRecord } from "../../../../../src/app-server/protocol/turn"; +import { createLocalIdSource } from "../../../../../src/shared/id/local-id"; import { chatStateMessageStreamItems, withChatStateMessageStreamItems } from "../../support/message-stream"; import { chatStateFixture, chatStateWith } from "../../support/state"; @@ -23,21 +24,25 @@ function controllerForState( ): ChatInboundController & { currentState(): ChatState } { const store = testStoreForState(state); return Object.assign( - new ChatInboundController(store, { - refreshActiveThreads: vi.fn(), - refreshRateLimits: vi.fn(), - refreshSkills: vi.fn(), - applyAppServerMetadataSnapshot: vi.fn(), - maybeNameThread: vi.fn(), - upsertActiveThread: vi.fn(), - applyThreadArchived: vi.fn(), - recordActiveThreadDeleted: vi.fn(), - applyThreadRenamed: vi.fn(), - recordMcpStartupStatus: vi.fn(), - respondToServerRequest: vi.fn(() => true), - rejectServerRequest: vi.fn(() => true), - ...actions, - }), + new ChatInboundController( + store, + { + refreshActiveThreads: vi.fn(), + refreshRateLimits: vi.fn(), + refreshSkills: vi.fn(), + applyAppServerMetadataSnapshot: vi.fn(), + maybeNameThread: vi.fn(), + upsertActiveThread: vi.fn(), + applyThreadArchived: vi.fn(), + recordActiveThreadDeleted: vi.fn(), + applyThreadRenamed: vi.fn(), + recordMcpStartupStatus: vi.fn(), + respondToServerRequest: vi.fn(() => true), + rejectServerRequest: vi.fn(() => true), + ...actions, + }, + createLocalIdSource({ nowMs: () => 1, seed: "test" }), + ), { currentState: () => store.getState(), }, diff --git a/tests/features/chat/threads/goal-actions.test.ts b/tests/features/chat/threads/goal-actions.test.ts index 6831ea6e..14188ee4 100644 --- a/tests/features/chat/threads/goal-actions.test.ts +++ b/tests/features/chat/threads/goal-actions.test.ts @@ -4,6 +4,7 @@ import type { AppServerClient } from "../../../../src/app-server/connection/clie import type { ThreadGoal } from "../../../../src/domain/threads/goal"; import { createGoalActions } from "../../../../src/features/chat/application/threads/goal-actions"; import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; +import { createLocalIdSource } from "../../../../src/shared/id/local-id"; import { deferred } from "../../../support/async"; import { chatStateFixture, chatStateWith } from "../support/state"; @@ -18,7 +19,9 @@ describe("createGoalActions", () => { const controller = createGoalActions({ stateStore, currentClient: () => client, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), ensureConnected: vi.fn().mockResolvedValue(undefined), + startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage: vi.fn(), addGoalEvent: vi.fn(), refreshLiveState, @@ -39,7 +42,9 @@ describe("createGoalActions", () => { const controller = createGoalActions({ stateStore, currentClient: () => client, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), ensureConnected: vi.fn().mockResolvedValue(undefined), + startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), refreshLiveState: vi.fn(), @@ -70,7 +75,9 @@ describe("createGoalActions", () => { const controller = createGoalActions({ stateStore, currentClient: () => client, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), ensureConnected: vi.fn().mockResolvedValue(undefined), + startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent, refreshLiveState: vi.fn(), @@ -101,7 +108,9 @@ describe("createGoalActions", () => { const controller = createGoalActions({ stateStore, currentClient: () => client, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), ensureConnected: vi.fn().mockResolvedValue(undefined), + startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), refreshLiveState: vi.fn(), @@ -127,7 +136,9 @@ describe("createGoalActions", () => { const controller = createGoalActions({ stateStore, currentClient: () => client, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), ensureConnected: vi.fn().mockResolvedValue(undefined), + startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), refreshLiveState: vi.fn(), @@ -154,7 +165,9 @@ describe("createGoalActions", () => { const controller = createGoalActions({ stateStore, currentClient: () => client, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), ensureConnected: vi.fn().mockResolvedValue(undefined), + startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent, refreshLiveState: vi.fn(), @@ -181,6 +194,73 @@ describe("createGoalActions", () => { ]); }); + it("starts a thread before saving a new goal objective when no thread is active", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + const savedGoal = goal({ threadId: "thread-new", objective: "Plan release" }); + const setThreadGoal = vi.fn().mockResolvedValueOnce({ goal: savedGoal }); + const injectThreadItems = vi.fn().mockResolvedValue({}); + const client = { setThreadGoal, injectThreadItems } as unknown as AppServerClient; + const startThread = vi.fn().mockImplementation(async () => { + stateStore.dispatch({ + type: "active-thread/resumed", + thread: { id: "thread-new", name: null, preview: "Plan release", archived: false, createdAt: 1, updatedAt: 1 }, + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalPolicy: null, + approvalsReviewer: null, + activePermissionProfile: null, + }); + return { threadId: "thread-new" }; + }); + const controller = createGoalActions({ + stateStore, + currentClient: () => client, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), + ensureConnected: vi.fn().mockResolvedValue(undefined), + startThread, + addSystemMessage: vi.fn(), + addGoalEvent: vi.fn(), + refreshLiveState: vi.fn(), + }); + + await expect(controller.saveObjective(" Plan release ", null)).resolves.toBe(true); + + expect(startThread).toHaveBeenCalledWith("Plan release", { syncGoal: false }); + expect(setThreadGoal).toHaveBeenCalledWith("thread-new", { objective: "Plan release", status: "active", tokenBudget: null }); + expect(injectThreadItems).toHaveBeenCalledWith("thread-new", [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Plan release" }], + }, + ]); + }); + + it("rejects empty goal objective saves before connecting or starting a thread", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + const ensureConnected = vi.fn().mockResolvedValue(undefined); + const startThread = vi.fn().mockResolvedValue({ threadId: "thread" }); + const addSystemMessage = vi.fn(); + const controller = createGoalActions({ + stateStore, + currentClient: () => ({ setThreadGoal: vi.fn() }) as unknown as AppServerClient, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), + ensureConnected, + startThread, + addSystemMessage, + addGoalEvent: vi.fn(), + refreshLiveState: vi.fn(), + }); + + await expect(controller.saveObjective(" ", null)).resolves.toBe(false); + + expect(addSystemMessage).toHaveBeenCalledWith("Goal objective cannot be empty."); + expect(ensureConnected).not.toHaveBeenCalled(); + expect(startThread).not.toHaveBeenCalled(); + }); + it("reports goal user history injection failures while the thread remains active", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); @@ -192,7 +272,9 @@ describe("createGoalActions", () => { const controller = createGoalActions({ stateStore, currentClient: () => client, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), ensureConnected: vi.fn().mockResolvedValue(undefined), + startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), refreshLiveState: vi.fn(), @@ -217,7 +299,9 @@ describe("createGoalActions", () => { const controller = createGoalActions({ stateStore, currentClient: () => client, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), ensureConnected: vi.fn().mockResolvedValue(undefined), + startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), refreshLiveState: vi.fn(), @@ -240,7 +324,9 @@ describe("createGoalActions", () => { const controller = createGoalActions({ stateStore, currentClient: () => client, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), ensureConnected: vi.fn().mockResolvedValue(undefined), + startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage: vi.fn(), addGoalEvent, refreshLiveState: vi.fn(), @@ -264,7 +350,9 @@ describe("createGoalActions", () => { const controller = createGoalActions({ stateStore, currentClient: () => client, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), ensureConnected: vi.fn().mockResolvedValue(undefined), + startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent, refreshLiveState: vi.fn(), @@ -286,7 +374,9 @@ describe("createGoalActions", () => { const controller = createGoalActions({ stateStore, currentClient: () => client, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), ensureConnected: vi.fn().mockResolvedValue(undefined), + startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), refreshLiveState: vi.fn(), diff --git a/tests/features/chat/threads/thread-management-actions.test.ts b/tests/features/chat/threads/thread-management-actions.test.ts index 340447f6..b3547aa4 100644 --- a/tests/features/chat/threads/thread-management-actions.test.ts +++ b/tests/features/chat/threads/thread-management-actions.test.ts @@ -37,6 +37,17 @@ describe("thread management actions", () => { expect(host.setStatus).toHaveBeenCalledWith("Compaction requested."); }); + it("reports compacting without an active thread", async () => { + const client = clientMock(); + const host = hostMock({ client, items: [] }); + const controller = threadManagementActions(host); + + await controller.compactActiveThread(); + + expect(host.addSystemMessage).toHaveBeenCalledWith("No active thread to compact."); + expect(client.compactThread).not.toHaveBeenCalled(); + }); + it("does not report compaction completion after the panel switches threads", async () => { const compact = deferred(); const client = clientMock();