Show newly set goals as user context

This commit is contained in:
murashit 2026-06-06 21:50:30 +09:00
parent 43221508e5
commit 2c8d0e058b
8 changed files with 194 additions and 11 deletions

View file

@ -21,6 +21,7 @@ import type { ThreadGoalClearResponse } from "../generated/app-server/v2/ThreadG
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";
import type { ThreadResumeResponse } from "../generated/app-server/v2/ThreadResumeResponse";
@ -87,6 +88,7 @@ interface ClientResponseByMethod {
"thread/goal/get": ThreadGoalGetResponse;
"thread/goal/set": ThreadGoalSetResponse;
"thread/goal/clear": ThreadGoalClearResponse;
"thread/inject_items": ThreadInjectItemsResponse;
"thread/list": ThreadListResponse;
"thread/read": ThreadReadResponse;
"thread/archive": ThreadArchiveResponse;
@ -296,6 +298,10 @@ export class AppServerClient {
return this.request("thread/goal/clear", { threadId });
}
injectThreadItems(threadId: string, items: ClientRequestParams<"thread/inject_items">["items"]): Promise<ThreadInjectItemsResponse> {
return this.request("thread/inject_items", { threadId, items });
}
updateThreadSettings(threadId: string, settings: Omit<ThreadSettingsUpdateParams, "threadId">): Promise<ThreadSettingsUpdateResponse> {
return this.request("thread/settings/update", { threadId, ...settings });
}

View file

@ -516,6 +516,9 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
currentClient: host.getClient,
ensureConnected: host.effects.client.ensureConnected,
addSystemMessage: host.effects.status.addSystemMessage,
addUserMessage: (item) => {
host.stateStore.dispatch({ type: "display/item-upserted", item });
},
render: host.effects.render.now,
refreshLiveState: host.effects.liveState.refresh,
});

View file

@ -1,7 +1,9 @@
import type { AppServerClient } from "../../app-server/client";
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 { MessageDisplayItem } from "./display/types";
import { goalChangeMessage } from "./goal-messages";
export interface ChatGoalControllerHost {
@ -9,6 +11,7 @@ export interface ChatGoalControllerHost {
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
addSystemMessage: (text: string) => void;
addUserMessage: (item: MessageDisplayItem) => void;
render: () => void;
refreshLiveState: () => void;
}
@ -39,11 +42,25 @@ export class ChatGoalController {
return false;
}
const current = this.host.stateStore.getState().activeGoal;
const applied = await this.setGoal(threadId, {
objective: trimmed,
status: current?.status ?? "active",
tokenBudget,
});
const isNewGoal = current === null;
const applied = await this.setGoal(
threadId,
{
objective: trimmed,
status: current?.status ?? "active",
tokenBudget,
},
isNewGoal
? {
beforeReportChange: () => {
this.host.addUserMessage(goalUserMessageItem(trimmed));
},
}
: undefined,
);
if (applied && isNewGoal) {
await this.recordGoalUserMessage(threadId, trimmed);
}
return applied;
}
@ -68,31 +85,69 @@ export class ChatGoalController {
private async setGoal(
threadId: string,
params: { objective?: string | null; status?: ThreadGoalStatus | null; tokenBudget?: number | null },
options: { beforeReportChange?: () => void } = {},
): Promise<boolean> {
await this.host.ensureConnected();
const client = this.host.currentClient();
if (!client) return false;
try {
const response = await client.setThreadGoal(threadId, params);
this.applyGoalIfActive(threadId, response.goal, { reportChange: true });
return true;
return this.applyGoalIfActive(threadId, response.goal, {
reportChange: true,
...(options.beforeReportChange ? { beforeReportChange: options.beforeReportChange } : {}),
});
} catch (error) {
this.host.addSystemMessage(errorMessage(error));
return false;
}
}
private applyGoalIfActive(threadId: string, goal: ThreadGoal | null, options: { reportChange: boolean }): void {
private applyGoalIfActive(
threadId: string,
goal: ThreadGoal | null,
options: { reportChange: boolean; beforeReportChange?: () => void },
): boolean {
const state = this.host.stateStore.getState();
if (state.activeThreadId !== threadId) return;
if (state.activeThreadId !== threadId) return false;
const message = options.reportChange ? goalChangeMessage(state.activeGoal, goal) : null;
this.host.stateStore.dispatch({ type: "thread/goal-set", goal });
options.beforeReportChange?.();
if (message) this.host.addSystemMessage(message);
this.host.refreshLiveState();
this.host.render();
return true;
}
private async recordGoalUserMessage(threadId: string, objective: string): Promise<void> {
const client = this.host.currentClient();
if (!client) return;
try {
await client.injectThreadItems(threadId, [goalUserHistoryItem(objective)]);
} catch (error) {
this.host.addSystemMessage(`Could not record goal message: ${errorMessage(error)}`);
}
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function goalUserMessageItem(text: string): MessageDisplayItem {
return {
id: `goal-user-${String(Date.now())}-${Math.random().toString(36).slice(2)}`,
kind: "message",
messageKind: "user",
role: "user",
text,
copyText: text,
};
}
function goalUserHistoryItem(text: string): JsonValue {
return {
type: "message",
role: "user",
content: [{ type: "input_text", text }],
};
}

View file

@ -7,7 +7,10 @@ const MAX_CONTEXT_CHARS = 4_000;
export function namingContextFromDisplayItems(turnId: string, items: readonly DisplayItem[]): ThreadNamingContext | null {
const turnItems = items.filter((item) => item.turnId === turnId);
const userRequest = turnItems.find((item) => item.kind === "message" && item.role === "user")?.text.trim() ?? "";
const userRequest =
turnItems.find((item) => item.kind === "message" && item.role === "user")?.text.trim() ??
precedingUnscopedUserMessage(turnId, items)?.text.trim() ??
"";
const assistantResponse = [...turnItems].reverse().find(isCompletedTurnOutcomeMessage)?.text.trim() ?? "";
if (!userRequest || !assistantResponse) return null;
return {
@ -27,6 +30,17 @@ export function firstNamingContextFromDisplayItems(items: readonly DisplayItem[]
return null;
}
function precedingUnscopedUserMessage(turnId: string, items: readonly DisplayItem[]): DisplayItem | null {
const firstTurnItemIndex = items.findIndex((item) => item.turnId === turnId);
if (firstTurnItemIndex < 1) return null;
for (let index = firstTurnItemIndex - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.turnId) return null;
if (item.kind === "message" && item.role === "user") return item;
}
return null;
}
function truncateForPrompt(text: string): string {
return truncate(text.replace(/\s+/g, " ").trim(), MAX_CONTEXT_CHARS);
}

View file

@ -158,6 +158,37 @@ describe("AppServerClient", () => {
expect(serverRequests[0]?.method).toBe("item/commandExecution/requestApproval");
});
it("injects raw items into a thread", async () => {
const { client, transport } = await connectedClient();
const request = client.injectThreadItems("thread-1", [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "Ship this" }],
},
]);
await expectRequest(
transport,
request,
{
method: "thread/inject_items",
params: {
threadId: "thread-1",
items: [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "Ship this" }],
},
],
},
},
{},
);
});
it("exposes initialized state through a single connection lifecycle", async () => {
const { client, transport } = await connectedClient();

View file

@ -19,6 +19,7 @@ describe("ChatGoalController", () => {
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
addSystemMessage: vi.fn(),
addUserMessage: vi.fn(),
render,
refreshLiveState,
});
@ -41,6 +42,7 @@ describe("ChatGoalController", () => {
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
addSystemMessage,
addUserMessage: vi.fn(),
render: vi.fn(),
refreshLiveState: vi.fn(),
});
@ -71,6 +73,7 @@ describe("ChatGoalController", () => {
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
addSystemMessage,
addUserMessage: vi.fn(),
render: vi.fn(),
refreshLiveState: vi.fn(),
});
@ -93,13 +96,16 @@ describe("ChatGoalController", () => {
state.activeThreadId = "thread";
const stateStore = createChatStateStore(state);
const setThreadGoal = vi.fn().mockResolvedValueOnce({ goal: goal() });
const client = { setThreadGoal } as unknown as AppServerClient;
const injectThreadItems = vi.fn().mockResolvedValue({});
const client = { setThreadGoal, injectThreadItems } as unknown as AppServerClient;
const addSystemMessage = vi.fn();
const addUserMessage = vi.fn();
const controller = new ChatGoalController({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
addSystemMessage,
addUserMessage,
render: vi.fn(),
refreshLiveState: vi.fn(),
});
@ -107,6 +113,40 @@ describe("ChatGoalController", () => {
await controller.setObjective("thread", "Finish", null);
expect(addSystemMessage).toHaveBeenCalledWith("Goal set.");
expect(addUserMessage).toHaveBeenCalledWith(expect.objectContaining({ kind: "message", messageKind: "user", text: "Finish" }));
expect(addUserMessage.mock.invocationCallOrder[0]).toBeLessThan(addSystemMessage.mock.invocationCallOrder[0] ?? 0);
expect(injectThreadItems).toHaveBeenCalledWith("thread", [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "Finish" }],
},
]);
});
it("does not add a goal user message when editing an existing goal", async () => {
const state = createChatState();
state.activeThreadId = "thread";
state.activeGoal = goal();
const stateStore = createChatStateStore(state);
const setThreadGoal = vi.fn().mockResolvedValueOnce({ goal: goal({ objective: "Updated" }) });
const injectThreadItems = vi.fn().mockResolvedValue({});
const client = { setThreadGoal, injectThreadItems } as unknown as AppServerClient;
const addUserMessage = vi.fn();
const controller = new ChatGoalController({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
addSystemMessage: vi.fn(),
addUserMessage,
render: vi.fn(),
refreshLiveState: vi.fn(),
});
await controller.setObjective("thread", "Updated", null);
expect(addUserMessage).not.toHaveBeenCalled();
expect(injectThreadItems).not.toHaveBeenCalled();
});
it("reports goal resume as a user-visible state change", async () => {
@ -122,6 +162,7 @@ describe("ChatGoalController", () => {
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
addSystemMessage,
addUserMessage: vi.fn(),
render: vi.fn(),
refreshLiveState: vi.fn(),
});
@ -143,6 +184,7 @@ describe("ChatGoalController", () => {
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
addSystemMessage,
addUserMessage: vi.fn(),
render: vi.fn(),
refreshLiveState: vi.fn(),
});

View file

@ -112,6 +112,26 @@ describe("thread naming", () => {
});
});
it("uses a preceding goal user message when the first completed turn has no user item", () => {
expect(
namingContextFromDisplayItems("turn", [
{ id: "goal-user", kind: "message", messageKind: "user", role: "user", text: "ゴールから始めたスレッドを命名したい" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "ゴール内容に基づいて実装しました。",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
]),
).toEqual({
userRequest: "ゴールから始めたスレッドを命名したい",
assistantResponse: "ゴール内容に基づいて実装しました。",
});
});
it("scans older thread pages until it finds a usable naming context", async () => {
const calls: { cursor: string | null; limit: number; sortDirection: string }[] = [];
const context = await findThreadNamingContext({

View file

@ -225,9 +225,20 @@ describe("CodexChatView connection lifecycle", () => {
status: "active",
tokenBudget: null,
});
expect(client.injectThreadItems).toHaveBeenCalledWith("thread-new", [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "Ship the feature" }],
},
]);
});
expect((view as unknown as { state: ChatState }).state.activeThreadId).toBe("thread-new");
expect((view as unknown as { state: ChatState }).state.activeGoal?.objective).toBe("Ship the feature");
expect((view as unknown as { state: ChatState }).state.displayItems).toContainEqual(
expect.objectContaining({ kind: "message", messageKind: "user", text: "Ship the feature" }),
);
expect(view.containerEl.textContent).toContain("Ship the feature");
});
it("ignores stale connection work after the view closes", async () => {
@ -894,6 +905,7 @@ function baseClient() {
setThreadName: vi.fn().mockResolvedValue({}),
getThreadGoal: vi.fn().mockResolvedValue({ goal: null }),
setThreadGoal: vi.fn().mockResolvedValue({ goal: goalFixture("thread-1") }),
injectThreadItems: vi.fn().mockResolvedValue({}),
readThread: vi.fn().mockResolvedValue({ thread: threadFixture("thread-1") }),
archiveThread: vi.fn().mockResolvedValue({}),
};