Model chat thread goals

This commit is contained in:
murashit 2026-06-10 11:53:20 +09:00
parent edf95e8244
commit 02fe06e21e
15 changed files with 118 additions and 33 deletions

View file

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

View file

@ -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<ThreadGoalSetResponse> {
return this.request("thread/goal/set", { threadId, ...params });
setThreadGoal(threadId: string, params: ThreadGoalUpdate): Promise<ThreadGoalSetResponse> {
return this.request("thread/goal/set", { threadId, ...appServerThreadGoalUpdate(params) });
}
clearThreadGoal(threadId: string): Promise<ThreadGoalClearResponse> {

View file

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

View file

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

View file

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

View file

@ -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<boolean> {
async function setGoal(host: ChatThreadGoalActionsHost, threadId: string, params: ThreadGoalUpdate): Promise<boolean> {
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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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