diff --git a/packages/core/src/agent/conversation-memory-checkpoint.test.ts b/packages/core/src/agent/conversation-memory-checkpoint.test.ts index 4d8c7a48..ee45b51f 100644 --- a/packages/core/src/agent/conversation-memory-checkpoint.test.ts +++ b/packages/core/src/agent/conversation-memory-checkpoint.test.ts @@ -307,7 +307,71 @@ describe("normalizeCheckpoint extras", () => { }); describe("buildCheckpointFromMessages", () => { - it("extracts objective from user messages and tool outcomes", () => { + it("does not promote ordinary chat into SUMMARY objectives", () => { + const messages: ChatMessage[] = [ + { role: "user", content: "你是谁?" }, + { role: "assistant", content: "我是 Step CLI 助手。" }, + { role: "user", content: "你去读取一下README的文件内容。" }, + ]; + + const result = buildCheckpointFromMessages(messages, { + fromIndex: 0, + toIndex: 3, + }); + + expect(result.objective).toEqual([]); + const summary = renderCheckpointText( + mergeCheckpoints(createEmptyCheckpoint(), result), + ); + expect(summary).not.toMatch(/Current Objective|Superseded Objectives/); + }); + + it("extracts explicit /goal and labeled objective signals", () => { + const messages: ChatMessage[] = [ + { role: "user", content: "你是谁?" }, + { role: "user", content: "/goal 读取 README 并总结" }, + { role: "user", content: "Goal: ship the release tonight" }, + ]; + + const result = buildCheckpointFromMessages(messages, { + fromIndex: 0, + toIndex: 3, + }); + + expect(result.objective?.map((entry) => entry.text)).toEqual([ + "读取 README 并总结", + "ship the release tonight", + ]); + expect(result.objective?.[0]!.status).toBe("superseded"); + expect(result.objective?.[1]!.status).toBe("still_active"); + }); + + it("extracts the goal text from a /goal wake prompt", () => { + const messages: ChatMessage[] = [ + { + role: "user", + content: [ + "You are working toward this long-running session goal:", + "", + "Migrate the auth flow", + "", + "Goal id: goal-123", + "Goal iteration: 1", + ].join("\n"), + }, + ]; + + const result = buildCheckpointFromMessages(messages, { + fromIndex: 0, + toIndex: 1, + }); + + expect(result.objective).toEqual([ + { text: "Migrate the auth flow", status: "still_active" }, + ]); + }); + + it("extracts tool outcomes without treating the user prompt as an objective", () => { const messages: ChatMessage[] = [ { role: "user", content: "Please fix the login bug" }, { @@ -353,7 +417,7 @@ describe("buildCheckpointFromMessages", () => { toIndex: 5, }); - expect(result.objective?.[0]!.text).toContain("login bug"); + expect(result.objective).toEqual([]); // Planned tools + assistant preview + tool summaries const actionTexts = (result.attemptedActions ?? []).map((a) => a.text); expect(actionTexts.some((t) => t.includes("Planned tools: Read"))).toBe( diff --git a/packages/core/src/agent/conversation-memory-checkpoint.ts b/packages/core/src/agent/conversation-memory-checkpoint.ts index f4bac48a..4125b8b0 100644 --- a/packages/core/src/agent/conversation-memory-checkpoint.ts +++ b/packages/core/src/agent/conversation-memory-checkpoint.ts @@ -13,6 +13,7 @@ import type { import { parseToolResult } from "./conversation-memory-tool-result.js"; import { dedupeMessagesKeepingNewest, + extractExplicitObjectiveText, renderTranscriptEntrySummary, } from "./conversation-memory-transcript.js"; @@ -581,8 +582,9 @@ function extractObjectiveCandidates( } const preview = userMessagePreviewText(message).trim(); - if (preview.length > 0) { - candidates.push(preview); + const objective = extractExplicitObjectiveText(preview); + if (objective) { + candidates.push(objective); } } diff --git a/packages/core/src/agent/conversation-memory-transcript.test.ts b/packages/core/src/agent/conversation-memory-transcript.test.ts new file mode 100644 index 00000000..ed0b14e1 --- /dev/null +++ b/packages/core/src/agent/conversation-memory-transcript.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import type { ChatMessage } from "@step-cli/protocol"; +import { + buildTranscriptSaveArtifact, + extractExplicitObjectiveText, +} from "./conversation-memory-transcript.js"; + +describe("extractExplicitObjectiveText", () => { + it("ignores ordinary chat prompts from issue #73", () => { + expect(extractExplicitObjectiveText("你是谁?")).toBeUndefined(); + expect( + extractExplicitObjectiveText("你去读取一下README的文件内容。"), + ).toBeUndefined(); + expect(extractExplicitObjectiveText("what's next?")).toBeUndefined(); + }); + + it("extracts /goal text and ignores control verbs", () => { + expect(extractExplicitObjectiveText("/goal 读取 README")).toBe( + "读取 README", + ); + expect(extractExplicitObjectiveText("/goal status")).toBeUndefined(); + expect(extractExplicitObjectiveText("/goal pause waiting")).toBeUndefined(); + expect( + extractExplicitObjectiveText("/goal start sess-1 ship the feature"), + ).toBe("sess-1 ship the feature"); + }); + + it("extracts labeled and wake-prompt objectives", () => { + expect(extractExplicitObjectiveText("Goal: ship the release")).toBe( + "ship the release", + ); + expect(extractExplicitObjectiveText("当前目标:迁移鉴权")).toBe("迁移鉴权"); + expect( + extractExplicitObjectiveText( + [ + "You are working toward this long-running session goal:", + "", + "Keep the SUMMARY panel accurate", + "", + "Goal id: goal-1", + "Goal iteration: 0", + ].join("\n"), + ), + ).toBe("Keep the SUMMARY panel accurate"); + }); +}); + +describe("buildTranscriptSaveArtifact", () => { + it("does not list ordinary user chat under Goals", () => { + const messages: ChatMessage[] = [ + { role: "user", content: "你是谁?" }, + { role: "assistant", content: "助手" }, + { role: "user", content: "/goal 读取 README" }, + ]; + + const artifact = buildTranscriptSaveArtifact({ + workspaceRoot: "/tmp", + sessionId: "sess", + summarizedFrom: 0, + summarizedTo: 3, + savedAt: "2026-06-19T10:12:37.000Z", + messages, + }); + + expect(artifact.entry.summaryPreview).toMatch(/Goals:\n- 读取 README/u); + expect(artifact.entry.summaryPreview).toContain("User turns:"); + expect(artifact.entry.summaryPreview).toContain("你是谁?"); + expect(artifact.entry.summaryPreview).not.toMatch(/Goals:\n- 你是谁?/u); + }); +}); diff --git a/packages/core/src/agent/conversation-memory-transcript.ts b/packages/core/src/agent/conversation-memory-transcript.ts index b4da586e..c06bdf03 100644 --- a/packages/core/src/agent/conversation-memory-transcript.ts +++ b/packages/core/src/agent/conversation-memory-transcript.ts @@ -178,15 +178,82 @@ export function buildTranscriptSaveArtifact( }; } +const GOAL_WAKE_INTRO = + "You are working toward this long-running session goal:"; +const GOAL_CONTROL_VERBS = new Set(["status", "pause", "resume", "stop"]); + +export function extractExplicitObjectiveText(text: string): string | undefined { + const raw = text.trim(); + if (raw.length === 0) { + return undefined; + } + + return ( + extractSlashGoalText(raw) ?? + extractGoalWakeText(raw) ?? + extractLabeledObjectiveText(raw) + ); +} + +function extractSlashGoalText(text: string): string | undefined { + const firstLine = text.split(/\n/u, 1)[0]?.trim() ?? ""; + const match = firstLine.match(/^\/goal(?:\s+(.+))?$/iu); + const rest = match?.[1]?.trim(); + if (!rest) { + return undefined; + } + + const [verb, ...remainder] = rest.split(/\s+/u); + if (!verb || GOAL_CONTROL_VERBS.has(verb.toLowerCase())) { + return undefined; + } + + if (verb.toLowerCase() === "start") { + const started = remainder.join(" ").trim(); + return started.length > 0 ? started : undefined; + } + + return rest; +} + +function extractGoalWakeText(text: string): string | undefined { + const introIndex = text.toLowerCase().indexOf(GOAL_WAKE_INTRO.toLowerCase()); + if (introIndex < 0) { + return undefined; + } + + const afterIntro = text.slice(introIndex + GOAL_WAKE_INTRO.length).trim(); + const goalBody = afterIntro.split(/\n\s*Goal id:/iu)[0]?.trim(); + return goalBody && goalBody.length > 0 + ? normalizeWhitespace(goalBody) + : undefined; +} + +function extractLabeledObjectiveText(text: string): string | undefined { + const labeled = + /(?:^|\n)\s*(?:current\s+(?:objective|goal)|objective|goal)\s*[::]\s*(.+)$/imu.exec( + text, + ) ?? /(?:^|\n)\s*(?:当前目标|目标)\s*[::]\s*(.+)$/mu.exec(text); + const value = labeled?.[1]?.trim(); + return value && value.length > 0 ? normalizeWhitespace(value) : undefined; +} + function summarizeMessages(messages: ChatMessage[]): string { const goals: string[] = []; + const userTurns: string[] = []; const actions: string[] = []; const outcomes: string[] = []; const issues: string[] = []; for (const message of messages) { if (message.role === "user") { - goals.push(shortenLine(userMessagePreviewText(message), 200)); + const preview = userMessagePreviewText(message); + const objective = extractExplicitObjectiveText(preview); + if (objective) { + goals.push(shortenLine(objective, 200)); + } else if (preview.trim().length > 0) { + userTurns.push(shortenLine(preview, 200)); + } continue; } @@ -227,6 +294,7 @@ function summarizeMessages(messages: ChatMessage[]): string { const lines: string[] = []; pushSection(lines, "Goals", goals, 6); + pushSection(lines, "User turns", userTurns, 6); pushSection(lines, "Actions", actions, 10); pushSection(lines, "Outcomes", outcomes, 8); pushSection(lines, "Issues", issues, 6); diff --git a/packages/core/src/agent/conversation-memory.test.ts b/packages/core/src/agent/conversation-memory.test.ts index 757070e2..2a9daa76 100644 --- a/packages/core/src/agent/conversation-memory.test.ts +++ b/packages/core/src/agent/conversation-memory.test.ts @@ -171,6 +171,42 @@ describe("ConversationMemory", () => { const result = memory.forceCompact("test"); expect(result.compactedMessages).toBeGreaterThan(0); }); + + it("does not promote ordinary chat into the SUMMARY objective sections", () => { + const memory = new ConversationMemory( + makeConfig({ minRecentMessages: 1 }), + ); + memory.addUser("你是谁?"); + memory.addAssistant("我是助手。"); + memory.addUser("你去读取一下README的文件内容。"); + memory.addAssistant("好的。"); + + const result = memory.forceCompact("issue-73"); + expect(result.compactedMessages).toBeGreaterThan(0); + + const summary = memory.exportState().summary; + expect(summary).not.toMatch(/Current Objective|Superseded Objectives/); + expect(summary).not.toContain("你是谁?"); + expect(summary).not.toContain("你去读取一下README的文件内容。"); + }); + + it("keeps explicit /goal text as the current SUMMARY objective", () => { + const memory = new ConversationMemory( + makeConfig({ minRecentMessages: 1 }), + ); + memory.addUser("你是谁?"); + memory.addAssistant("我是助手。"); + memory.addUser("/goal 读取 README 并总结"); + memory.addAssistant("开始读取。"); + + memory.forceCompact("issue-73"); + + const summary = memory.exportState().summary; + expect(summary).toContain("Current Objective:"); + expect(summary).toContain("读取 README 并总结"); + expect(summary).not.toContain("你是谁?"); + expect(summary).not.toMatch(/Superseded Objectives/); + }); }); describe("loadState with checkpoint", () => {