From 2c8d0e058b72e014a8d9f110080f85ecc351c88a Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 6 Jun 2026 21:50:30 +0900 Subject: [PATCH] Show newly set goals as user context --- src/app-server/client.ts | 6 ++ .../chat/chat-view-controller-assembly.ts | 3 + src/features/chat/goal-controller.ts | 73 ++++++++++++++++--- src/features/chat/thread-naming.ts | 16 +++- tests/app-server/app-server-client.test.ts | 31 ++++++++ tests/features/chat/goal-controller.test.ts | 44 ++++++++++- tests/features/chat/thread-naming.test.ts | 20 +++++ tests/features/chat/view-connection.test.ts | 12 +++ 8 files changed, 194 insertions(+), 11 deletions(-) diff --git a/src/app-server/client.ts b/src/app-server/client.ts index 192b7131..0fd8415f 100644 --- a/src/app-server/client.ts +++ b/src/app-server/client.ts @@ -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 { + return this.request("thread/inject_items", { threadId, items }); + } + updateThreadSettings(threadId: string, settings: Omit): Promise { return this.request("thread/settings/update", { threadId, ...settings }); } diff --git a/src/features/chat/chat-view-controller-assembly.ts b/src/features/chat/chat-view-controller-assembly.ts index 40fe0158..c20978dc 100644 --- a/src/features/chat/chat-view-controller-assembly.ts +++ b/src/features/chat/chat-view-controller-assembly.ts @@ -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, }); diff --git a/src/features/chat/goal-controller.ts b/src/features/chat/goal-controller.ts index c6d69e7e..5444bc9f 100644 --- a/src/features/chat/goal-controller.ts +++ b/src/features/chat/goal-controller.ts @@ -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; 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 { 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 { + 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 }], + }; +} diff --git a/src/features/chat/thread-naming.ts b/src/features/chat/thread-naming.ts index df44ad15..c30897b9 100644 --- a/src/features/chat/thread-naming.ts +++ b/src/features/chat/thread-naming.ts @@ -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); } diff --git a/tests/app-server/app-server-client.test.ts b/tests/app-server/app-server-client.test.ts index 9d8b0ad9..725bbd51 100644 --- a/tests/app-server/app-server-client.test.ts +++ b/tests/app-server/app-server-client.test.ts @@ -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(); diff --git a/tests/features/chat/goal-controller.test.ts b/tests/features/chat/goal-controller.test.ts index 922277ea..9ad203a9 100644 --- a/tests/features/chat/goal-controller.test.ts +++ b/tests/features/chat/goal-controller.test.ts @@ -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(), }); diff --git a/tests/features/chat/thread-naming.test.ts b/tests/features/chat/thread-naming.test.ts index 8d95c9bf..732df1e5 100644 --- a/tests/features/chat/thread-naming.test.ts +++ b/tests/features/chat/thread-naming.test.ts @@ -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({ diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index b37994ec..eb6a850f 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -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({}), };