From 69d56437c758c4905be989efa86e18f2ee542e22 Mon Sep 17 00:00:00 2001 From: PerryLink <255665900+PerryLink@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:30:41 +0800 Subject: [PATCH] fix: list only human turns in /history and prefer the model surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host attributes synthesized user-role messages through `source.kind` — `plugin` for context injections and compaction checkpoints, `goal` for goal rounds, `session-reference` for recalls, `agent-message` for relays — and the GUI sidebar lists only `kind === "user"` (session-turn-outline). `/history` filtered by event type alone, so compaction checkpoints and injected context were listed as if the user had typed them. The persisted fallback also read the complete raw log first, which carries every non-surface injection. - ops.ts: `historyEntries()` + `isHumanUserMessage()` shared by both paths; legacy payloads without a `source` stay visible, so nothing real is hidden. - ops.ts: the persisted fallback now prefers `sessionQuery.readSurface()` (current model surface) and only then `readSession()` (complete raw log). - history-provenance.test.ts: live and cold cases — injected plugin/goal messages hidden, legacy payloads still visible, `readSurface` preferred over `readSession`. Verified with the repo's own gates on Node 22: `npm run build` clean, `npm test` 426/426 passing. --- src/dsh/ops.ts | 86 +++++++++----- tests/history-provenance.test.ts | 197 +++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+), 27 deletions(-) create mode 100644 tests/history-provenance.test.ts diff --git a/src/dsh/ops.ts b/src/dsh/ops.ts index 578df06..33c9abc 100644 --- a/src/dsh/ops.ts +++ b/src/dsh/ops.ts @@ -100,6 +100,14 @@ export interface SessionQuery { readTitle(sessionId: string): Promise<{ title?: string } | undefined>; /** Lightweight raw-log event records (ascending seq), for recency recovery. */ listEvents(sessionId: string): Promise>; + /** + * Complete current model surface (`SessionSurfaceSnapshot`, 0.1.5+): the + * events the model actually sees, each carrying its own `data`. Preferred + * persisted history source — one fold, no raw-log replay. + */ + readSurface?(sessionId: string): Promise<{ + events?: Array<{ type: string; time?: number; data?: unknown }>; + }>; /** * Full validated log (0.1.2+). `listEvents` no longer carries `data`, so * `/history` prefers this when the host exposes it. @@ -590,6 +598,41 @@ export class DshOps { return ""; } + /** + * Whether one `user/message` payload is a genuine human turn. + * + * The host attributes synthesized user-role messages to their producer + * through `source.kind` — `plugin` for context injections and compaction + * checkpoints, `goal` for goal rounds, `session-reference` for recalls, + * `agent-message` for relays — and the GUI sidebar lists only + * `kind === "user"` (session-turn-outline). Legacy payloads without a + * source stay visible. + */ + private isHumanUserMessage(data: unknown): boolean { + if (!data || typeof data !== "object") return true; + const d = data as Record; + const direct = (d.source as { kind?: unknown } | undefined)?.kind; + const nested = ((d.message as Record | undefined)?.source as { kind?: unknown } | undefined)?.kind; + const kind = direct ?? nested; + return kind === undefined || kind === "user"; + } + + /** Fold chronological history entries out of surface or raw-log events. */ + private historyEntries( + events: readonly { type: string; time?: number; data?: unknown }[], + ): HistoryEntry[] { + const entries: HistoryEntry[] = []; + for (const ev of events) { + if (ev.type !== "user/message" && ev.type !== "assistant/message") continue; + if (ev.type === "user/message" && !this.isHumanUserMessage(ev.data)) continue; + const text = this.extractHistoryText(ev.data); + if (!text) continue; + const role = ev.type === "user/message" ? "user" as const : "assistant" as const; + entries.push({ role, text, time: typeof ev.time === "number" ? ev.time : Date.now() }); + } + return entries; + } + /** * Retrieve the most recent `limit` conversation entries (user + assistant) * for `sessionId`, ordered oldest→newest. @@ -598,12 +641,14 @@ export class DshOps { * 1. Try in-memory `session.snapshotEvents()` (0.1.5) or the legacy * `session.events` array via `ctx.get("agents")` — fast, no I/O, * survives even when `sessionQuery` is unavailable. - * 2. Fall back to persisted `sessionQuery.listEvents(sessionId)` — works - * after restart or when agent is not live. + * 2. Fall back to persisted `sessionQuery.readSurface(sessionId)` (current + * model surface), then `readSession(sessionId)` (complete raw log). + * `listEvents` is metadata-only since 0.1.2 and cannot reconstruct text. * * Filters to `user/message` (role=user) and `assistant/message` * (role=assistant). Other event types (tool results, system, etc.) are - * ignored to keep the WeChat view concise. + * ignored to keep the WeChat view concise, and synthesized user-role + * messages (see {@link isHumanUserMessage}) never masquerade as turns. * * Returns `[]` on any error or when no history exists — caller renders * a friendly empty-state message. @@ -616,14 +661,7 @@ export class DshOps { const agent = agents?.get(sessionId); const events = sessionEvents(agent?.session); if (events.length > 0) { - const entries: HistoryEntry[] = []; - for (const ev of events) { - if (ev.type !== "user/message" && ev.type !== "assistant/message") continue; - const text = this.extractHistoryText(ev.data); - if (!text) continue; - const role = ev.type === "user/message" ? "user" as const : "assistant" as const; - entries.push({ role, text, time: typeof ev.time === "number" ? ev.time : Date.now() }); - } + const entries = this.historyEntries(events); if (entries.length > 0) { // events are already in chronological order (ascending seq) return entries.slice(-cap); @@ -633,27 +671,21 @@ export class DshOps { // fall through to persisted path } - // 2) Persisted fallback. Prefer `readSession` (full events with `data`); - // `listEvents` in 0.1.2 is metadata-only and cannot reconstruct text. + // 2) Persisted fallback: current model surface first, then the raw log. const query = this.get("sessionQuery"); if (!query) return []; try { - let records: Array<{ type: string; time?: number; data?: unknown }> = []; - if (typeof query.readSession === "function") { - const snapshot = await query.readSession(sessionId); - records = snapshot.events ?? []; - } else { - records = await query.listEvents(sessionId); + let records: readonly { type: string; time?: number; data?: unknown }[] = []; + if (typeof query.readSurface === "function") { + records = (await query.readSurface(sessionId))?.events ?? []; + } + if (records.length === 0 && typeof query.readSession === "function") { + records = (await query.readSession(sessionId))?.events ?? []; } - const entries: HistoryEntry[] = []; - for (const r of records) { - if (r.type !== "user/message" && r.type !== "assistant/message") continue; - const text = this.extractHistoryText(r.data); - if (!text) continue; - const role = r.type === "user/message" ? "user" as const : "assistant" as const; - entries.push({ role, text, time: typeof r.time === "number" ? r.time : Date.now() }); + if (records.length === 0) { + records = await query.listEvents(sessionId); } - return entries.slice(-cap); + return this.historyEntries(records).slice(-cap); } catch { return []; } diff --git a/tests/history-provenance.test.ts b/tests/history-provenance.test.ts new file mode 100644 index 0000000..95d5562 --- /dev/null +++ b/tests/history-provenance.test.ts @@ -0,0 +1,197 @@ +/** + * `/history` provenance filtering: + * - Only genuine human turns are listed. The host stamps synthesized + * user-role messages with `source.kind` (`plugin` for context injections + * and compaction checkpoints, `goal` for goal rounds, `session-reference` + * for recalls), and the GUI sidebar lists only `kind === "user"`. + * - The persisted fallback prefers `sessionQuery.readSurface()` (current + * model surface) and only then `readSession()` (complete raw log). + * - Legacy payloads without a `source` stay visible, so nothing real is + * hidden by the filter. + */ + +import { describe, expect, it, vi, beforeEach } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +const sendTextMessage = vi.fn().mockResolvedValue(undefined); +const sendMediaMessage = vi.fn().mockResolvedValue(undefined); + +vi.mock("../src/weixin/send.js", () => ({ + sendTextMessage: (...args: unknown[]) => sendTextMessage(...args), + sendMediaMessage: (...args: unknown[]) => sendMediaMessage(...args), + splitText: (text: string, maxLen: number) => + text.length <= maxLen ? [text] : [text.slice(0, maxLen), text.slice(maxLen)], +})); + +vi.mock("../src/weixin/api.js", () => ({ + sendTyping: () => Promise.resolve(undefined), + getConfig: () => Promise.resolve({ typing_ticket: "tk" }), + isSessionTimeoutError: () => false, + isMessageLimitError: () => false, + isInvalidRequestError: () => false, +})); + +import { WeChatDSHBridge } from "../src/bridge/bridge.js"; +import { defaultConfig } from "../src/config.js"; + +interface EventLike { + type: string; + time?: number; + data?: unknown; +} + +/** Host shape: `user/message` data is the message itself (role/source/content). */ +function humanEvent(text: string, time = 1): EventLike { + return { + type: "user/message", + time, + data: { id: `user-${time}`, role: "user", source: { kind: "user" }, content: [{ type: "text", text }] }, + }; +} + +/** Host shape: compaction checkpoints / context injections are user-role too. */ +function injectedEvent(text: string, kind = "plugin", time = 2): EventLike { + return { + type: "user/message", + time, + data: { + id: `checkpoint-${time}`, + role: "user", + source: kind === "plugin" ? { kind, plugin: "compact" } : { kind, goalId: "g1", revision: 1, round: 0 }, + content: [{ type: "text", text }], + }, + }; +} + +function assistantEvent(text: string, time = 3): EventLike { + return { + type: "assistant/message", + time, + data: { turn: 1, step: 1, message: { content: [{ type: "text", text }] } }, + }; +} + +function boundBridge(ctx: { get(name: string): unknown; on(): () => void }): WeChatDSHBridge { + const cfg = defaultConfig(); + cfg.storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-wx-hist-prov-")); + const bridge = new WeChatDSHBridge(ctx as never, cfg); + (bridge as unknown as { token: unknown }).token = { baseUrl: "https://x", token: "t" }; + const state = (bridge as unknown as { + state: { ensureUser(u: string, c: string): unknown; update(u: string, p: unknown): void }; + }).state; + state.ensureUser("u1", "C:\\work"); + state.update("u1", { sessionId: "wx-1" }); + return bridge; +} + +function makeLiveBridge(events: EventLike[]): WeChatDSHBridge { + const agent = { session: { events } }; + const agentsService = { + create: async () => undefined, + resume: async () => undefined, + get: (id: string) => (id === "wx-1" ? (agent as never) : undefined), + list: () => [agent as never], + }; + return boundBridge({ get: (name: string) => (name === "agents" ? agentsService : undefined), on: () => () => {} }); +} + +function makeColdBridge(query: Record): WeChatDSHBridge { + return boundBridge({ get: (name: string) => (name === "sessionQuery" ? query : undefined), on: () => () => {} }); +} + +async function runHistory(bridge: WeChatDSHBridge): Promise { + sendTextMessage.mockClear(); + await (bridge as unknown as { handleMessage: (m: unknown) => Promise }).handleMessage({ + message_type: 1, + from_user_id: "u1", + context_token: "ctx", + item_list: [{ type: 1, text_item: { text: "/history" } }], + }); + return sendTextMessage.mock.calls.map((c) => String(c[1])).join("\n"); +} + +beforeEach(() => { + vi.clearAllMocks(); + sendTextMessage.mockResolvedValue(undefined); + sendMediaMessage.mockResolvedValue(undefined); +}); + +describe("/history provenance", () => { + it("live history hides injected user-role messages but keeps human turns", async () => { + const bridge = makeLiveBridge([ + humanEvent("人类提问", 1), + injectedEvent("【压缩检查点】被替换掉的历史正文", "plugin", 2), + assistantEvent("助手回复", 3), + ]); + + const text = await runHistory(bridge); + + expect(text).toContain("人类提问"); + expect(text).toContain("助手回复"); + expect(text).not.toContain("压缩检查点"); + }); + + it("live history hides goal-round user messages too", async () => { + const bridge = makeLiveBridge([ + humanEvent("人类提问", 1), + injectedEvent("目标续跑提示词", "goal", 2), + ]); + + const text = await runHistory(bridge); + + expect(text).toContain("人类提问"); + expect(text).not.toContain("目标续跑提示词"); + }); + + it("legacy payloads without a source stay visible", async () => { + const bridge = makeLiveBridge([ + { type: "user/message", time: 1, data: { message: { content: [{ type: "text", text: "legacy user" }] } } }, + ]); + + const text = await runHistory(bridge); + + expect(text).toContain("legacy user"); + }); + + it("cold history prefers readSurface and applies the same provenance filter", async () => { + const readSession = vi.fn(async () => ({ + events: [humanEvent("raw-log user", 1), assistantEvent("raw-log assistant", 2)], + })); + const bridge = makeColdBridge({ + listSessions: async () => [], + readTitle: async () => undefined, + listEvents: async () => [], + readSurface: async () => ({ + events: [ + humanEvent("surface user", 1), + injectedEvent("【压缩检查点】surface 注入", "plugin", 2), + assistantEvent("surface assistant", 3), + ], + }), + readSession, + }); + + const text = await runHistory(bridge); + + expect(text).toContain("surface user"); + expect(text).toContain("surface assistant"); + expect(text).not.toContain("压缩检查点"); + expect(readSession).not.toHaveBeenCalled(); + }); + + it("cold history falls back to readSession when no surface is exposed", async () => { + const bridge = makeColdBridge({ + listSessions: async () => [], + readTitle: async () => undefined, + listEvents: async () => [], + readSession: async () => ({ events: [humanEvent("cold user", 1), assistantEvent("cold assistant", 2)] }), + }); + + const text = await runHistory(bridge); + + expect(text).toContain("cold user"); + expect(text).toContain("cold assistant"); + }); +});