Report completed thread goals

This commit is contained in:
murashit 2026-06-06 21:17:38 +09:00
parent 6006b878f4
commit 364dc70bb3
5 changed files with 183 additions and 32 deletions

View file

@ -24,6 +24,7 @@ import {
import type { DisplayItem, DisplayKind, MessageDisplayItem } from "./display/types";
import { planProgressDisplayItem } from "./display/plan";
import { createSystemItem } from "./display/system";
import { goalChangeMessage } from "./goal-messages";
import { attachHookRunsToTurn, hookRunDisplayItem } from "./hook-display";
import { routeServerNotification } from "./inbound-routing";
@ -69,7 +70,7 @@ export function planChatNotification(
case "turnLifecycle":
return planTurnLifecycle(state, route.notification);
case "threadLifecycle":
return planThreadLifecycle(state, route.notification);
return planThreadLifecycle(state, route.notification, localItemId);
case "requestResolved":
return {
actions: [{ type: "request/resolved", requestId: route.notification.params.requestId }],
@ -191,7 +192,7 @@ function planTurnLifecycle(state: ChatState, notification: ServerNotification):
return EMPTY_PLAN;
}
function planThreadLifecycle(state: ChatState, notification: ServerNotification): ChatNotificationPlan {
function planThreadLifecycle(state: ChatState, notification: ServerNotification, localItemId: LocalItemIdFactory): ChatNotificationPlan {
const { method, params } = notification;
if (method === "thread/started") {
if (!state.activeThreadId || state.activeThreadId === params.thread.id) {
@ -239,11 +240,21 @@ function planThreadLifecycle(state: ChatState, notification: ServerNotification)
}
if (method === "thread/goal/updated") {
if (state.activeThreadId !== params.threadId) return EMPTY_PLAN;
return actionPlan({ type: "thread/goal-set", goal: params.goal });
const actions: ChatAction[] = [{ type: "thread/goal-set", goal: params.goal }];
const message = goalChangeMessage(state.activeGoal, params.goal);
if (message) {
actions.push({ type: "system/message-added", item: createSystemItem(localItemId("system"), message) });
}
return { actions, effects: [] };
}
if (method === "thread/goal/cleared") {
if (state.activeThreadId !== params.threadId) return EMPTY_PLAN;
return actionPlan({ type: "thread/goal-set", goal: null });
const actions: ChatAction[] = [{ type: "thread/goal-set", goal: null }];
const message = goalChangeMessage(state.activeGoal, null);
if (message) {
actions.push({ type: "system/message-added", item: createSystemItem(localItemId("system"), message) });
}
return { actions, effects: [] };
}
return EMPTY_PLAN;
}

View file

@ -2,6 +2,7 @@ import type { AppServerClient } from "../../app-server/client";
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 { goalChangeMessage } from "./goal-messages";
export interface ChatGoalControllerHost {
stateStore: ChatStateStore;
@ -24,7 +25,7 @@ export class ChatGoalController {
if (!client) return;
try {
const response = await client.getThreadGoal(threadId);
this.applyGoalIfActive(threadId, response.goal);
this.applyGoalIfActive(threadId, response.goal, { reportChange: false });
} catch (error) {
if (this.host.stateStore.getState().activeThreadId !== threadId) return;
this.host.addSystemMessage(`Could not load thread goal: ${errorMessage(error)}`);
@ -43,14 +44,11 @@ export class ChatGoalController {
status: current?.status ?? "active",
tokenBudget,
});
if (applied) this.addSystemMessageIfActive(threadId, current ? "Goal updated." : "Goal set.");
return applied;
}
async setStatus(threadId: string, status: ThreadGoalStatus): Promise<boolean> {
const applied = await this.setGoal(threadId, { status });
if (applied) this.addSystemMessageIfActive(threadId, goalStatusMessage(status));
return applied;
return this.setGoal(threadId, { status });
}
async clear(threadId: string): Promise<boolean> {
@ -59,8 +57,7 @@ export class ChatGoalController {
if (!client) return false;
try {
await client.clearThreadGoal(threadId);
this.applyGoalIfActive(threadId, null);
this.addSystemMessageIfActive(threadId, "Goal cleared.");
this.applyGoalIfActive(threadId, null, { reportChange: true });
return true;
} catch (error) {
this.host.addSystemMessage(errorMessage(error));
@ -77,7 +74,7 @@ export class ChatGoalController {
if (!client) return false;
try {
const response = await client.setThreadGoal(threadId, params);
this.applyGoalIfActive(threadId, response.goal);
this.applyGoalIfActive(threadId, response.goal, { reportChange: true });
return true;
} catch (error) {
this.host.addSystemMessage(errorMessage(error));
@ -85,25 +82,17 @@ export class ChatGoalController {
}
}
private applyGoalIfActive(threadId: string, goal: ThreadGoal | null): void {
if (this.host.stateStore.getState().activeThreadId !== threadId) return;
private applyGoalIfActive(threadId: string, goal: ThreadGoal | null, options: { reportChange: boolean }): void {
const state = this.host.stateStore.getState();
if (state.activeThreadId !== threadId) return;
const message = options.reportChange ? goalChangeMessage(state.activeGoal, goal) : null;
this.host.stateStore.dispatch({ type: "thread/goal-set", goal });
if (message) this.host.addSystemMessage(message);
this.host.refreshLiveState();
this.host.render();
}
private addSystemMessageIfActive(threadId: string, text: string): void {
if (this.host.stateStore.getState().activeThreadId !== threadId) return;
this.host.addSystemMessage(text);
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function goalStatusMessage(status: ThreadGoalStatus): string {
if (status === "paused") return "Goal paused.";
if (status === "active") return "Goal resumed.";
return `Goal status set to ${status}.`;
}

View file

@ -0,0 +1,28 @@
import type { ThreadGoal } from "../../generated/app-server/v2/ThreadGoal";
import type { ThreadGoalStatus } from "../../generated/app-server/v2/ThreadGoalStatus";
export function goalChangeMessage(previous: ThreadGoal | null, next: ThreadGoal | null): string | null {
if (!previous && next) return "Goal set.";
if (previous && !next) return "Goal cleared.";
if (!previous || !next) return null;
if (previous.status !== next.status) return goalStatusMessage(next.status);
if (previous.objective !== next.objective || previous.tokenBudget !== next.tokenBudget) return "Goal updated.";
return null;
}
function goalStatusMessage(status: ThreadGoalStatus): string {
switch (status) {
case "active":
return "Goal resumed.";
case "paused":
return "Goal paused.";
case "complete":
return "Goal completed.";
case "blocked":
return "Goal blocked.";
case "usageLimited":
return "Goal usage limited.";
case "budgetLimited":
return "Goal budget limited.";
}
}

View file

@ -1375,7 +1375,7 @@ describe("ChatController", () => {
expect(state.activeServiceTier).toBeNull();
});
it("applies goal notifications to thread state without adding system messages", () => {
it("adds system messages for goal state changes from notifications", () => {
const state = createChatState();
state.activeThreadId = "thread-active";
const controller = controllerForState(state);
@ -1396,7 +1396,48 @@ describe("ChatController", () => {
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.activeGoal).toEqual(goal);
expect(state.displayItems).toEqual([]);
expect(state.displayItems.at(-1)).toMatchObject({ kind: "system", text: "Goal set." });
const afterSetMessageCount = state.displayItems.length;
controller.handleNotification({
method: "thread/goal/updated",
params: { threadId: "thread-active", turnId: null, goal },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.displayItems).toHaveLength(afterSetMessageCount);
const updatedGoal = { ...goal, objective: "Finish well", updatedAt: 2 };
controller.handleNotification({
method: "thread/goal/updated",
params: { threadId: "thread-active", turnId: null, goal: updatedGoal },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.displayItems.at(-1)).toMatchObject({ kind: "system", text: "Goal updated." });
const pausedGoal = { ...updatedGoal, status: "paused", updatedAt: 3 } satisfies Extract<
ServerNotification,
{ method: "thread/goal/updated" }
>["params"]["goal"];
controller.handleNotification({
method: "thread/goal/updated",
params: { threadId: "thread-active", turnId: null, goal: pausedGoal },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.displayItems.at(-1)).toMatchObject({ kind: "system", text: "Goal paused." });
const resumedGoal = { ...pausedGoal, status: "active", updatedAt: 4 } satisfies Extract<
ServerNotification,
{ method: "thread/goal/updated" }
>["params"]["goal"];
controller.handleNotification({
method: "thread/goal/updated",
params: { threadId: "thread-active", turnId: null, goal: resumedGoal },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.displayItems.at(-1)).toMatchObject({ kind: "system", text: "Goal resumed." });
const messageCount = state.displayItems.length;
controller.handleNotification({
method: "thread/goal/updated",
params: { threadId: "thread-active", turnId: null, goal: { ...resumedGoal, tokensUsed: 10, timeUsedSeconds: 20 } },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.displayItems).toHaveLength(messageCount);
controller.handleNotification({
method: "thread/goal/cleared",
@ -1404,7 +1445,46 @@ describe("ChatController", () => {
} satisfies Extract<ServerNotification, { method: "thread/goal/cleared" }>);
expect(state.activeGoal).toBeNull();
expect(state.displayItems).toEqual([]);
expect(state.displayItems.at(-1)).toMatchObject({ kind: "system", text: "Goal cleared." });
});
it("adds a system message when a goal completes", () => {
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeGoal = {
threadId: "thread-active",
objective: "Finish",
status: "active",
tokenBudget: null,
tokensUsed: 12,
timeUsedSeconds: 60,
createdAt: 1,
updatedAt: 1,
};
const controller = controllerForState(state);
const completedGoal = {
...state.activeGoal,
status: "complete",
tokensUsed: 42,
timeUsedSeconds: 120,
updatedAt: 2,
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>["params"]["goal"];
controller.handleNotification({
method: "thread/goal/updated",
params: { threadId: "thread-active", turnId: "turn-1", goal: completedGoal },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.activeGoal).toEqual(completedGoal);
expect(state.displayItems).toHaveLength(1);
expect(state.displayItems[0]).toMatchObject({ kind: "system", text: "Goal completed." });
controller.handleNotification({
method: "thread/goal/updated",
params: { threadId: "thread-active", turnId: "turn-1", goal: { ...completedGoal, tokensUsed: 43 } },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(state.displayItems).toHaveLength(1);
});
it("ignores goal notifications that do not match the active thread", () => {

View file

@ -58,7 +58,8 @@ describe("ChatGoalController", () => {
state.activeGoal = goal({ tokenBudget: 500 });
const stateStore = createChatStateStore(state);
const updated = goal({ objective: "Updated", tokenBudget: 250 });
const setThreadGoal = vi.fn().mockResolvedValue({ goal: updated });
const paused = goal({ objective: "Updated", status: "paused", tokenBudget: 250 });
const setThreadGoal = vi.fn().mockResolvedValueOnce({ goal: updated }).mockResolvedValueOnce({ goal: paused });
const clearThreadGoal = vi.fn().mockResolvedValue({ cleared: true });
const client = {
setThreadGoal,
@ -87,11 +88,11 @@ describe("ChatGoalController", () => {
expect(stateStore.getState().activeGoal).toBeNull();
});
it("reports goal creation and resume as user-visible system messages", async () => {
it("reports goal creation as a user-visible state change", async () => {
const state = createChatState();
state.activeThreadId = "thread";
const stateStore = createChatStateStore(state);
const setThreadGoal = vi.fn().mockResolvedValue({ goal: goal() });
const setThreadGoal = vi.fn().mockResolvedValueOnce({ goal: goal() });
const client = { setThreadGoal } as unknown as AppServerClient;
const addSystemMessage = vi.fn();
const controller = new ChatGoalController({
@ -104,11 +105,53 @@ describe("ChatGoalController", () => {
});
await controller.setObjective("thread", "Finish", null);
await controller.setStatus("thread", "active");
expect(addSystemMessage).toHaveBeenCalledWith("Goal set.");
});
it("reports goal resume as a user-visible state change", async () => {
const state = createChatState();
state.activeThreadId = "thread";
state.activeGoal = goal({ status: "paused" });
const stateStore = createChatStateStore(state);
const setThreadGoal = vi.fn().mockResolvedValueOnce({ goal: goal() });
const client = { setThreadGoal } as unknown as AppServerClient;
const addSystemMessage = vi.fn();
const controller = new ChatGoalController({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
addSystemMessage,
render: vi.fn(),
refreshLiveState: vi.fn(),
});
await controller.setStatus("thread", "active");
expect(addSystemMessage).toHaveBeenCalledWith("Goal resumed.");
});
it("does not report initial goal sync as a user-visible state change", async () => {
const state = createChatState();
state.activeThreadId = "thread";
const stateStore = createChatStateStore(state);
const currentGoal = goal();
const client = { getThreadGoal: vi.fn().mockResolvedValue({ goal: currentGoal }) } as unknown as AppServerClient;
const addSystemMessage = vi.fn();
const controller = new ChatGoalController({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
addSystemMessage,
render: vi.fn(),
refreshLiveState: vi.fn(),
});
await controller.syncThreadGoal("thread");
expect(stateStore.getState().activeGoal).toEqual(currentGoal);
expect(addSystemMessage).not.toHaveBeenCalled();
});
});
function goal(overrides: Partial<ThreadGoal> = {}): ThreadGoal {