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"); + }); +});