Localize chat side effects and decisions

This commit is contained in:
murashit 2026-06-18 08:17:59 +09:00
parent d3f62d8228
commit 29fa227fac
25 changed files with 427 additions and 275 deletions

View file

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

View file

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

View file

@ -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",

View file

@ -187,11 +187,11 @@ async function probeDiagnostic<T>(
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()) };
}
}

View file

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

View file

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

View file

@ -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<boolean>;
startThread: (preview?: string) => Promise<unknown>;
@ -30,6 +31,14 @@ export interface TurnSubmissionActionsHost {
addSystemMessage: (text: string) => void;
}
type TurnSubmissionSnapshot = ReturnType<typeof submissionStateSnapshot>;
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<boolean> {
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<TurnSubmissionPlan, { kind: "steer" }>,
text: string,
codexInputOverride?: CodexInput,
referencedThread?: ReferencedThreadMetadata,
): Promise<void> {
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));
}

View file

@ -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<void>;
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<boolean>;
setObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise<boolean>;
setStatus: (threadId: string, status: ThreadGoalStatus) => Promise<boolean>;
clear: (threadId: string) => Promise<boolean>;
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<void> {
async function syncThreadGoal(host: ThreadGoalSyncHost, threadId: string): Promise<void> {
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<boolean> {
const trimmed = objective.trim();
if (!trimmed) {
async function setObjective(host: GoalActionsHost, threadId: string, objective: string, tokenBudget: number | null): Promise<boolean> {
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<boolean> {
return setGoal(host, localItemIds, threadId, { status });
async function saveObjective(host: GoalActionsHost, objective: string, tokenBudget: number | null): Promise<boolean> {
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<boolean> {
function setGoalStatus(host: GoalActionsHost, threadId: string, status: ThreadGoalStatus): Promise<boolean> {
return setGoal(host, threadId, { status });
}
async function clearGoal(host: GoalActionsHost, threadId: string): Promise<boolean> {
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<boolean> {
async function setGoal(host: GoalActionsHost, threadId: string, params: ThreadGoalUpdate): Promise<boolean> {
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<GoalObjectiveSavePlan, { kind: "start-thread-and-save" }>,
): Promise<boolean> {
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<void> {
const client = host.currentClient();
if (!client) return;

View file

@ -30,6 +30,7 @@ export interface ThreadManagementActionsHost {
}
export interface ThreadManagementActions {
compactActiveThread: () => Promise<void>;
compactThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise<void>;
forkThread: (threadId: string) => Promise<void>;
@ -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<void> {
const threadId = threadManagementState(host).activeThread.id;
if (!threadId) {
host.addSystemMessage(noActiveThreadToCompactMessage());
return;
}
await compactThread(host, threadId);
}
async function compactThread(host: ThreadManagementActionsHost, threadId: string): Promise<void> {
await host.ensureConnected();
const client = host.currentClient();

View file

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

View file

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

View file

@ -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<void>;
saveObjective: (objective: string, tokenBudget: number | null) => Promise<boolean>;
setStatus: (threadId: string, status: "active" | "paused") => Promise<unknown>;
clear: (threadId: string) => Promise<unknown>;
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<void> {
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;

View file

@ -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<void>;
}
export interface ChatPanelToolbarActionDependencies {
connectionController: ChatConnectionController;
reconnectPanel: () => Promise<void>;
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<ChatStateStore["getState"]>,
deps: Pick<ChatPanelToolbarActionDependencies, "inboundController" | "threadActions">,
): Promise<void> {
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);
}

View file

@ -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",

View file

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

View file

@ -193,7 +193,7 @@ function model(modelId: string): ModelMetadata {
}
function serverMetadata(overrides: Partial<SharedServerMetadata> = {}): SharedServerMetadata {
const diagnostics = diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("model/list", "0 models"));
const diagnostics = diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("model/list", "0 models", 1));
return {
runtimeConfig: null,
availableModels: [],

View file

@ -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<SharedServerMetadata | null>>().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,

View file

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

View file

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

View file

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

View file

@ -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<typeof vi.fn>).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<typeof createChatPanelToolbarActions>[1
typeof createChatPanelToolbarActions
>[1]["connectionController"],
reconnectPanel: vi.fn(),
inboundController: { addSystemMessage: vi.fn() } as unknown as Parameters<typeof createChatPanelToolbarActions>[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<typeof createChatPanelToolbarActions>[1]["goals"],
toolbarPanels: {
toggleChatActions: vi.fn(),
toggleHistory: vi.fn(),

View file

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

View file

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

View file

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

View file

@ -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<undefined>();
const client = clientMock();