mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Show newly set goals as user context
This commit is contained in:
parent
43221508e5
commit
2c8d0e058b
8 changed files with 194 additions and 11 deletions
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({}),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue