From 02fe06e21e778afd2a154f6eeb6acdc9bcdb26ad Mon Sep 17 00:00:00 2001 From: murashit Date: Wed, 10 Jun 2026 11:53:20 +0900 Subject: [PATCH] Model chat thread goals --- eslint.config.mjs | 4 -- src/app-server/client.ts | 9 +-- src/app-server/thread-goal.ts | 59 +++++++++++++++++++ src/features/chat/chat-state.ts | 2 +- src/features/chat/display/goal-messages.ts | 3 +- .../chat/threads/thread-goal-actions.ts | 18 +++--- .../chat/turns/slash-command-actions.ts | 3 +- .../chat/turns/slash-command-execution.ts | 3 +- src/features/chat/ui/goal-banner.tsx | 3 +- tests/app-server/thread-goal.test.ts | 37 ++++++++++++ .../features/chat/chat-state-reducer.test.ts | 2 +- .../chat/display/goal-messages.test.ts | 2 +- .../chat/threads/thread-goal-actions.test.ts | 2 +- .../turns/slash-command-execution.test.ts | 2 +- .../chat/ui/renderers/goal-banner.test.tsx | 2 +- 15 files changed, 118 insertions(+), 33 deletions(-) create mode 100644 src/app-server/thread-goal.ts create mode 100644 tests/app-server/thread-goal.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 0704a0e1..f28ca9c6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -99,7 +99,6 @@ const generatedAppServerImportLegacyFiles = [ "src/features/chat/chat-state-actions.ts", "src/features/chat/chat-state.ts", "src/features/chat/display/agent.ts", - "src/features/chat/display/goal-messages.ts", "src/features/chat/display/hooks.ts", "src/features/chat/display/labels.ts", "src/features/chat/display/permission-details.ts", @@ -116,9 +115,6 @@ const generatedAppServerImportLegacyFiles = [ "src/features/chat/threads/thread-history-controller.ts", "src/features/chat/threads/thread-rename-controller.ts", "src/features/chat/threads/thread-resume.ts", - "src/features/chat/turns/slash-command-actions.ts", - "src/features/chat/turns/slash-command-execution.ts", - "src/features/chat/ui/goal-banner.tsx", "src/features/chat/ui/pending-request-message.tsx", ]; const codexPanelEslintPlugin = { diff --git a/src/app-server/client.ts b/src/app-server/client.ts index 2e21ab79..c892fa7e 100644 --- a/src/app-server/client.ts +++ b/src/app-server/client.ts @@ -20,7 +20,6 @@ import type { ThreadForkResponse } from "../generated/app-server/v2/ThreadForkRe import type { ThreadGoalClearResponse } from "../generated/app-server/v2/ThreadGoalClearResponse"; import type { ThreadGoalGetResponse } from "../generated/app-server/v2/ThreadGoalGetResponse"; import type { ThreadGoalSetResponse } from "../generated/app-server/v2/ThreadGoalSetResponse"; -import type { ThreadGoalStatus } from "../generated/app-server/v2/ThreadGoalStatus"; import type { ThreadInjectItemsResponse } from "../generated/app-server/v2/ThreadInjectItemsResponse"; import type { ThreadListResponse } from "../generated/app-server/v2/ThreadListResponse"; import type { ThreadReadResponse } from "../generated/app-server/v2/ThreadReadResponse"; @@ -45,6 +44,7 @@ import type { ServerNotification } from "../generated/app-server/ServerNotificat import type { ServerRequest } from "../generated/app-server/ServerRequest"; import type { JsonValue } from "../generated/app-server/serde_json/JsonValue"; import { toAppServerUserInput, type CodexInput } from "./request-input"; +import { appServerThreadGoalUpdate, type ThreadGoalUpdate } from "./thread-goal"; import type { ServiceTierRequest, ThreadSettingsUpdate } from "./thread-settings"; const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; @@ -293,11 +293,8 @@ export class AppServerClient { return this.request("thread/goal/get", { threadId }); } - setThreadGoal( - threadId: string, - params: { objective?: string | null; status?: ThreadGoalStatus | null; tokenBudget?: number | null }, - ): Promise { - return this.request("thread/goal/set", { threadId, ...params }); + setThreadGoal(threadId: string, params: ThreadGoalUpdate): Promise { + return this.request("thread/goal/set", { threadId, ...appServerThreadGoalUpdate(params) }); } clearThreadGoal(threadId: string): Promise { diff --git a/src/app-server/thread-goal.ts b/src/app-server/thread-goal.ts new file mode 100644 index 00000000..cc3db3ae --- /dev/null +++ b/src/app-server/thread-goal.ts @@ -0,0 +1,59 @@ +import type { ThreadGoal as AppServerThreadGoal } from "../generated/app-server/v2/ThreadGoal"; +import type { ThreadGoalStatus as AppServerThreadGoalStatus } from "../generated/app-server/v2/ThreadGoalStatus"; + +export type ThreadGoalStatus = "active" | "paused" | "blocked" | "usageLimited" | "budgetLimited" | "complete"; + +export interface ThreadGoal { + threadId: string; + objective: string; + status: ThreadGoalStatus; + tokenBudget: number | null; + tokensUsed: number; + timeUsedSeconds: number; + createdAt: number; + updatedAt: number; +} + +export interface ThreadGoalUpdate { + objective?: string | null; + status?: ThreadGoalStatus | null; + tokenBudget?: number | null; +} + +export function threadGoalFromAppServerGoal(goal: AppServerThreadGoal | null): ThreadGoal | null { + if (!goal) return null; + return { + threadId: goal.threadId, + objective: goal.objective, + status: threadGoalStatusFromAppServerStatus(goal.status), + tokenBudget: goal.tokenBudget, + tokensUsed: finiteNumber(goal.tokensUsed), + timeUsedSeconds: finiteNumber(goal.timeUsedSeconds), + createdAt: finiteNumber(goal.createdAt), + updatedAt: finiteNumber(goal.updatedAt), + }; +} + +export function appServerThreadGoalUpdate(update: ThreadGoalUpdate): { + objective?: string | null; + status?: AppServerThreadGoalStatus | null; + tokenBudget?: number | null; +} { + return { + ...("objective" in update ? { objective: update.objective } : {}), + ...("status" in update ? { status: update.status === null ? null : appServerThreadGoalStatus(update.status) } : {}), + ...("tokenBudget" in update ? { tokenBudget: update.tokenBudget } : {}), + }; +} + +function threadGoalStatusFromAppServerStatus(status: AppServerThreadGoalStatus): ThreadGoalStatus { + return status; +} + +function appServerThreadGoalStatus(status: ThreadGoalStatus): AppServerThreadGoalStatus { + return status; +} + +function finiteNumber(value: number): number { + return Number.isFinite(value) ? value : 0; +} diff --git a/src/features/chat/chat-state.ts b/src/features/chat/chat-state.ts index 5b82f7f8..5dd6e5e5 100644 --- a/src/features/chat/chat-state.ts +++ b/src/features/chat/chat-state.ts @@ -2,7 +2,7 @@ import type { InitializeResponse } from "../../generated/app-server/InitializeRe import type { ReasoningEffort } from "../../domain/catalog/metadata"; import type { Thread } from "../../domain/threads/model"; import type { ModelMetadata, SkillMetadata } from "../../domain/catalog/metadata"; -import type { ThreadGoal } from "../../generated/app-server/v2/ThreadGoal"; +import type { ThreadGoal } from "../../app-server/thread-goal"; import type { ThreadSettingsUpdate } from "../../app-server/thread-settings"; import type { ApprovalsReviewer } from "../../app-server/runtime-policy"; import type { Diagnostics } from "../../app-server/diagnostics"; diff --git a/src/features/chat/display/goal-messages.ts b/src/features/chat/display/goal-messages.ts index cafb7155..e254f0bf 100644 --- a/src/features/chat/display/goal-messages.ts +++ b/src/features/chat/display/goal-messages.ts @@ -1,5 +1,4 @@ -import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal"; -import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus"; +import type { ThreadGoal, ThreadGoalStatus } from "../../../app-server/thread-goal"; import { truncate } from "../../../utils"; import type { GoalDisplayItem } from "./types"; diff --git a/src/features/chat/threads/thread-goal-actions.ts b/src/features/chat/threads/thread-goal-actions.ts index 92311804..a24aa78e 100644 --- a/src/features/chat/threads/thread-goal-actions.ts +++ b/src/features/chat/threads/thread-goal-actions.ts @@ -1,7 +1,11 @@ import type { AppServerClient } from "../../../app-server/client"; +import { + threadGoalFromAppServerGoal, + type ThreadGoal, + type ThreadGoalStatus, + type ThreadGoalUpdate, +} from "../../../app-server/thread-goal"; import type { JsonValue } from "../../../generated/app-server/serde_json/JsonValue"; -import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal"; -import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus"; import type { ChatStateStore } from "../chat-state"; import type { GoalDisplayItem } from "../display/types"; import { goalChangeItem } from "../display/goal-messages"; @@ -39,7 +43,7 @@ async function syncThreadGoal(host: ChatThreadGoalActionsHost, threadId: string) if (!client) return; try { const response = await client.getThreadGoal(threadId); - applyGoalIfActive(host, threadId, response.goal, { reportChange: false }); + applyGoalIfActive(host, threadId, threadGoalFromAppServerGoal(response.goal), { reportChange: false }); } catch (error) { if (host.stateStore.getState().activeThread.id !== threadId) return; host.addSystemMessage(`Could not load thread goal: ${errorMessage(error)}`); @@ -88,17 +92,13 @@ async function clearGoal(host: ChatThreadGoalActionsHost, threadId: string): Pro } } -async function setGoal( - host: ChatThreadGoalActionsHost, - threadId: string, - params: { objective?: string | null; status?: ThreadGoalStatus | null; tokenBudget?: number | null }, -): Promise { +async function setGoal(host: ChatThreadGoalActionsHost, threadId: string, params: ThreadGoalUpdate): Promise { await host.ensureConnected(); const client = host.currentClient(); if (!client) return false; try { const response = await client.setThreadGoal(threadId, params); - return applyGoalIfActive(host, threadId, response.goal, { reportChange: true }); + return applyGoalIfActive(host, threadId, threadGoalFromAppServerGoal(response.goal), { reportChange: true }); } catch (error) { host.addSystemMessage(errorMessage(error)); return false; diff --git a/src/features/chat/turns/slash-command-actions.ts b/src/features/chat/turns/slash-command-actions.ts index e196f6da..bd5d6d5f 100644 --- a/src/features/chat/turns/slash-command-actions.ts +++ b/src/features/chat/turns/slash-command-actions.ts @@ -11,8 +11,7 @@ import { import type { SlashCommandName } from "../composer/slash-commands"; import type { DisplayDetailSection } from "../display/types"; import type { ReasoningEffort } from "../../../domain/catalog/metadata"; -import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal"; -import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus"; +import type { ThreadGoal, ThreadGoalStatus } from "../../../app-server/thread-goal"; import { submissionStateSnapshot } from "../chat-state-selectors"; import type { ChatStateStore } from "../chat-state"; diff --git a/src/features/chat/turns/slash-command-execution.ts b/src/features/chat/turns/slash-command-execution.ts index c2005e5e..632dcfe9 100644 --- a/src/features/chat/turns/slash-command-execution.ts +++ b/src/features/chat/turns/slash-command-execution.ts @@ -1,8 +1,7 @@ import type { CodexInput } from "../../../app-server/request-input"; +import type { ThreadGoal, ThreadGoalStatus } from "../../../app-server/thread-goal"; import type { ReasoningEffort } from "../../../domain/catalog/metadata"; import type { Thread } from "../../../domain/threads/model"; -import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal"; -import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus"; import { getThreadTitle } from "../../../domain/threads/model"; import type { ReferencedThreadDisplay } from "../../../domain/threads/reference"; import { diff --git a/src/features/chat/ui/goal-banner.tsx b/src/features/chat/ui/goal-banner.tsx index 515355b9..a62e244a 100644 --- a/src/features/chat/ui/goal-banner.tsx +++ b/src/features/chat/ui/goal-banner.tsx @@ -1,8 +1,7 @@ import type { ComponentChild as UiNode } from "preact"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; -import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal"; -import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus"; +import type { ThreadGoal, ThreadGoalStatus } from "../../../app-server/thread-goal"; import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboard"; import { IconButton } from "../../../shared/ui/components"; import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow"; diff --git a/tests/app-server/thread-goal.test.ts b/tests/app-server/thread-goal.test.ts new file mode 100644 index 00000000..8d251d3c --- /dev/null +++ b/tests/app-server/thread-goal.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { appServerThreadGoalUpdate, threadGoalFromAppServerGoal } from "../../src/app-server/thread-goal"; + +describe("app-server thread goal model", () => { + it("maps app-server goals into panel-owned goal snapshots", () => { + expect( + threadGoalFromAppServerGoal({ + threadId: "thread", + objective: "Finish", + status: "active", + tokenBudget: 100, + tokensUsed: Number.POSITIVE_INFINITY, + timeUsedSeconds: 12, + createdAt: 1, + updatedAt: 2, + }), + ).toEqual({ + threadId: "thread", + objective: "Finish", + status: "active", + tokenBudget: 100, + tokensUsed: 0, + timeUsedSeconds: 12, + createdAt: 1, + updatedAt: 2, + }); + }); + + it("serializes partial goal updates without inventing omitted fields", () => { + expect(appServerThreadGoalUpdate({ status: "paused" })).toEqual({ status: "paused" }); + expect(appServerThreadGoalUpdate({ objective: "Updated", tokenBudget: null })).toEqual({ + objective: "Updated", + tokenBudget: null, + }); + }); +}); diff --git a/tests/features/chat/chat-state-reducer.test.ts b/tests/features/chat/chat-state-reducer.test.ts index 1db56dfa..70918668 100644 --- a/tests/features/chat/chat-state-reducer.test.ts +++ b/tests/features/chat/chat-state-reducer.test.ts @@ -10,9 +10,9 @@ import { transitionChatTurnLifecycleState, type ChatState, } from "../../../src/features/chat/chat-state"; +import type { ThreadGoal } from "../../../src/app-server/thread-goal"; import type { DisplayItem } from "../../../src/features/chat/display/types"; import type { Thread } from "../../../src/generated/app-server/v2/Thread"; -import type { ThreadGoal } from "../../../src/generated/app-server/v2/ThreadGoal"; describe("chatReducer", () => { it("clears active turn and thread-scoped state", () => { diff --git a/tests/features/chat/display/goal-messages.test.ts b/tests/features/chat/display/goal-messages.test.ts index 4182adf5..081e83af 100644 --- a/tests/features/chat/display/goal-messages.test.ts +++ b/tests/features/chat/display/goal-messages.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; +import type { ThreadGoal } from "../../../../src/app-server/thread-goal"; import { goalChangeItem } from "../../../../src/features/chat/display/goal-messages"; -import type { ThreadGoal } from "../../../../src/generated/app-server/v2/ThreadGoal"; describe("goal display items", () => { it("keeps goal event summaries compact while retaining the full objective in details", () => { diff --git a/tests/features/chat/threads/thread-goal-actions.test.ts b/tests/features/chat/threads/thread-goal-actions.test.ts index e96db553..ba640b35 100644 --- a/tests/features/chat/threads/thread-goal-actions.test.ts +++ b/tests/features/chat/threads/thread-goal-actions.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import type { AppServerClient } from "../../../../src/app-server/client"; +import type { ThreadGoal } from "../../../../src/app-server/thread-goal"; import { createChatThreadGoalActions } from "../../../../src/features/chat/threads/thread-goal-actions"; import { createChatState, createChatStateStore } from "../../../../src/features/chat/chat-state"; -import type { ThreadGoal } from "../../../../src/generated/app-server/v2/ThreadGoal"; describe("createChatThreadGoalActions", () => { it("syncs the active thread goal into chat state", async () => { diff --git a/tests/features/chat/turns/slash-command-execution.test.ts b/tests/features/chat/turns/slash-command-execution.test.ts index 9e6114fd..c367fdcd 100644 --- a/tests/features/chat/turns/slash-command-execution.test.ts +++ b/tests/features/chat/turns/slash-command-execution.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; +import type { ThreadGoal } from "../../../../src/app-server/thread-goal"; import { slashCommandHelpLines, slashCommandHelpSections } from "../../../../src/features/chat/composer/slash-commands"; import type { Thread } from "../../../../src/generated/app-server/v2/Thread"; -import type { ThreadGoal } from "../../../../src/generated/app-server/v2/ThreadGoal"; import { executeSlashCommand, type SlashCommandExecutionContext } from "../../../../src/features/chat/turns/slash-command-execution"; function context(overrides: Partial = {}): SlashCommandExecutionContext { diff --git a/tests/features/chat/ui/renderers/goal-banner.test.tsx b/tests/features/chat/ui/renderers/goal-banner.test.tsx index 555ca7e5..fb02e53e 100644 --- a/tests/features/chat/ui/renderers/goal-banner.test.tsx +++ b/tests/features/chat/ui/renderers/goal-banner.test.tsx @@ -3,8 +3,8 @@ import { describe, expect, it, vi } from "vitest"; import { act } from "preact/test-utils"; +import type { ThreadGoal } from "../../../../../src/app-server/thread-goal"; import { renderGoalBanner } from "../../../../../src/features/chat/ui/goal-banner"; -import type { ThreadGoal } from "../../../../../src/generated/app-server/v2/ThreadGoal"; import type { SendShortcut } from "../../../../../src/shared/ui/keyboard"; import { installObsidianDomShims } from "../../../../support/dom";