Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 59 additions & 27 deletions src/dsh/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<{ type: string; time: number; data?: unknown; seq?: number }>>;
/**
* 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.
Expand Down Expand Up @@ -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<string, unknown>;
const direct = (d.source as { kind?: unknown } | undefined)?.kind;
const nested = ((d.message as Record<string, unknown> | 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.
Expand All @@ -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.
Expand All @@ -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);
Expand All @@ -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>("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 [];
}
Expand Down
197 changes: 197 additions & 0 deletions tests/history-provenance.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): WeChatDSHBridge {
return boundBridge({ get: (name: string) => (name === "sessionQuery" ? query : undefined), on: () => () => {} });
}

async function runHistory(bridge: WeChatDSHBridge): Promise<string> {
sendTextMessage.mockClear();
await (bridge as unknown as { handleMessage: (m: unknown) => Promise<void> }).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");
});
});
Loading