From 63813b1eb7d8f196a6285d9b2bb4fe4f52a4a711 Mon Sep 17 00:00:00 2001 From: irisfeng <6022447+irisfeng@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:34:53 +0800 Subject: [PATCH] core: skip the model for human-directed web project mentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In web project chats, a message from a human that @-mentions another human member is person-to-person communication routed through the bot. Running it through the model costs a turn, adds latency, and can make the model interject where nobody asked it to. Append the message to the session transcript and end the turn silently when the incoming web group turn is human-originated, carries no approval and no attachments, and mentions another named audience member. Matching runs on the display text, not the wake envelope the spine rewrite substitutes for input.text — envelope metadata carries the sender's identity and would trip the bot-name veto for any sender whose id contains the bot label. CJK-aware matching: no-space boundaries, single-character surname prefixes stay ambiguous, ASCII tokens that merely start with a member name do not match, self- and bot-mentions are ignored, emails do not count. Turns with attachments keep the model path so inbound files are materialized and viewable instead of persisting unfetchable blob references. --- src/core/orchestrator.ts | 24 +++++ src/core/orchestrator/human-mention.ts | 34 ++++++ test/turn-human-directed.test.ts | 143 +++++++++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 src/core/orchestrator/human-mention.ts create mode 100644 test/turn-human-directed.test.ts diff --git a/src/core/orchestrator.ts b/src/core/orchestrator.ts index b19b393fa..93d142f45 100644 --- a/src/core/orchestrator.ts +++ b/src/core/orchestrator.ts @@ -133,6 +133,7 @@ import { randomUUID } from "node:crypto"; import { LRUCache } from "lru-cache"; import type { SkillResolution, GrantedSkillRef } from "../skills/skill-store.ts"; import type { Orchestrator, OrchestratorDeps, OrchestratorInput } from "./orchestrator/types.ts"; +import { mentionsOtherMember } from "./orchestrator/human-mention.ts"; import { resolveModel } from "../model/pi-models.ts"; import { MAX_AUTO_ATTACHMENT_SCREEN_BYTES, @@ -841,6 +842,15 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { const branding = await resolveBranding(deps.config, resolution.orgScopeId, deps.brandingDefault); const botName = branding.selfLabel ?? "QM"; const orgName = branding.orgName ?? "this organization"; + const humanText = + typeof input.displayText === "string" && input.displayText ? input.displayText : input.text; + const humanDirected = + isWeb && + conversation.kind === "group" && + input.origin.kind === "human" && + !input.approval && + !input.attachments?.length && + mentionsOtherMember(humanText, conversation.audience, actor.id, botName); const rawHandle = cleanBrandingLabel(input.gatewayContext?.botHandle?.replace(/^@/, ""), 40); const botHandle = rawHandle && rawHandle.toLowerCase() !== botName.toLowerCase() ? rawHandle : undefined; let modeName = "mode-fallback"; @@ -1412,6 +1422,20 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { await Promise.all(pendingScreenRequests.splice(0).map(recordScreenRequest)); return true; }); + if (humanDirected) { + await withManagedRosterVersion(async () => { + await deps.sessions.append(lease, { + type: "user", + payload: { + text: humanText, + ...(actor.displayName?.trim() ? { name: actor.displayName.trim() } : {}), + }, + scopeLabel: scopeId, + }); + return true; + }); + return { status: "silent", sessionId: session.id }; + } if (input.approval) { const p = await pending.get(input.approval.requestId); if (!input.approval.approved) { diff --git a/src/core/orchestrator/human-mention.ts b/src/core/orchestrator/human-mention.ts new file mode 100644 index 000000000..2313280a6 --- /dev/null +++ b/src/core/orchestrator/human-mention.ts @@ -0,0 +1,34 @@ +import type { Principal } from "../../types.ts"; + +const MENTION_BODY = /^[\p{L}\p{N}_.·-]+/u; + +export function mentionsOtherMember( + text: string, + audience: readonly Principal[], + selfId: string, + botName: string, +): boolean { + if (!text.includes("@")) return false; + const bot = botName.trim().toLowerCase(); + if (bot && text.toLowerCase().includes(bot)) return false; + const names = audience + .filter((p) => p.id !== selfId && p.displayName?.trim()) + .map((p) => p.displayName!.trim().toLowerCase()); + if (!names.length) return false; + const tokens = new Set(); + let i = text.indexOf("@"); + while (i !== -1) { + const prev = i > 0 ? text[i - 1]! : ""; + const m = text.slice(i + 1).match(MENTION_BODY); + if (m && m[0] && !/[A-Za-z0-9]/.test(prev)) tokens.add(m[0].toLowerCase()); + i = text.indexOf("@", i + 1); + } + return [...tokens].some((t) => + names.some((n) => { + if (n === t) return true; + if (t.length < 2 || n.length < 2) return false; + if (n.startsWith(t)) return true; + return t.startsWith(n) && /[^\x00-\x7F]/.test(t.slice(n.length)); + }), + ); +} diff --git a/test/turn-human-directed.test.ts b/test/turn-human-directed.test.ts new file mode 100644 index 000000000..c7ec1acbb --- /dev/null +++ b/test/turn-human-directed.test.ts @@ -0,0 +1,143 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import "./support/auto-fake-sprites.ts"; +import { buildApp } from "../src/wiring.ts"; +import type { TurnRequest } from "../src/types.ts"; +import { testConfig } from "./support/test-config.ts"; +import { mentionsOtherMember } from "../src/core/orchestrator/human-mention.ts"; +import type { Principal } from "../src/types.ts"; + +const p = (id: string, displayName: string): Principal => ({ id, displayName, type: "internal" }); +const audience: Principal[] = [ + p("zhangsan@example.com", "张三"), + p("lisi@example.com", "李四"), + p("wangwu@example.com", "王五"), +]; + +describe("mentionsOtherMember", () => { + it("detects a full-name mention mid-sentence without spaces", () => { + assert.equal(mentionsOtherMember("帮我看下@李四这个方案", audience, "zhangsan@example.com", "QM"), true); + }); + + it("treats an ambiguous single-char surname prefix as not human-directed", () => { + assert.equal(mentionsOtherMember("@李 你看下", audience, "zhangsan@example.com", "QM"), false); + assert.equal(mentionsOtherMember("@李四的方案 我看看", audience, "zhangsan@example.com", "QM"), true); + }); + + it("ignores a self-mention", () => { + assert.equal(mentionsOtherMember("@张三 记一下", audience, "zhangsan@example.com", "QM"), false); + }); + + it("ignores unknown tokens and emails", () => { + assert.equal(mentionsOtherMember("发给 a@b.com 了", audience, "zhangsan@example.com", "QM"), false); + assert.equal(mentionsOtherMember("@赵六 在吗", audience, "zhangsan@example.com", "QM"), false); + }); + + it("ignores text without any at-sign", () => { + assert.equal(mentionsOtherMember("李四 看一下", audience, "zhangsan@example.com", "QM"), false); + }); + + it("returns false when the audience has no other named members", () => { + assert.equal(mentionsOtherMember("@李四 看", [p("x", "张三")], "x", "QM"), false); + }); + + it("does not treat a message naming the bot as human-directed", () => { + assert.equal(mentionsOtherMember("@李四 和 QM 一起看看", audience, "zhangsan@example.com", "QM"), false); + }); + + it("does not match a longer ascii word that merely starts with a member name", () => { + const asciiAudience = [ + p("sam@example.com", "Sam"), + p("al@example.com", "Al"), + p("ann@example.com", "Ann"), + ]; + assert.equal( + mentionsOtherMember("@sample the new endpoint and post the results", asciiAudience, "x@example.com", "QM"), + false, + ); + assert.equal(mentionsOtherMember("@all hands", asciiAudience, "x@example.com", "QM"), false); + assert.equal(mentionsOtherMember("@announce it", asciiAudience, "x@example.com", "QM"), false); + }); + + it("still matches a name extended by CJK suffixes", () => { + const mixed = [p("amy@example.com", "Amy"), ...audience]; + assert.equal(mentionsOtherMember("@amy看看这个", mixed, "zhangsan@example.com", "QM"), true); + }); +}); + +describe("web group human-directed turns", () => { + function freshApp() { + return buildApp(testConfig({ dataDir: mkdtempSync(join(tmpdir(), "hm-")) })); + } + + async function projectFixture( + ownerId: string, + ownerName: string, + memberId: string, + memberName: string, + ): Promise<{ app: ReturnType["app"]; groupRef: string }> { + const built = freshApp(); + await built.app.upsertDirectory([ + { principalId: ownerId, displayName: ownerName, type: "internal" }, + { principalId: memberId, displayName: memberName, type: "internal" }, + ]); + const project = await built.projects.create({ name: "Launch", ownerId }); + await built.projects.addMember(project.id, ownerId, memberId); + return { app: built.app, groupRef: `web-project-${project.id}` }; + } + + function groupTurn( + actor: { externalId: string; displayName?: string }, + groupRef: string, + text: string, + extra: Partial = {}, + ): TurnRequest { + return { + surface: "web", + actor, + origin: { kind: "human" }, + conversation: { kind: "group", channelRef: groupRef, threadRef: `${groupRef}:t1`, audience: [actor] }, + text, + ...extra, + }; + } + + it("appends the typed message and stays silent", async () => { + const { app, groupRef } = await projectFixture("U1", "张三", "U2", "李四"); + const res = await app.turn(groupTurn({ externalId: "U1" }, groupRef, "@李四 看下这个方案")); + assert.equal(res.status, "silent", res.reason); + const found = await app.getSession(res.sessionId!); + assert.deepEqual(found!.entries.map((e) => e.type), ["user"]); + assert.equal((found!.entries[0]!.payload as { text: string }).text, "@李四 看下这个方案"); + }); + + it("matches the typed message, not spine-envelope metadata that happens to contain the bot label", async () => { + const { app, groupRef } = await projectFixture("qiming@acme.com", "Qiming", "U2", "李四"); + const res = await app.turn( + groupTurn({ externalId: "qiming@acme.com" }, groupRef, "@李四 看下这个方案"), + ); + assert.equal(res.status, "silent", "a sender whose id contains the bot label must not be vetoed"); + }); + + it("a mention carrying attachments still reaches the model", async () => { + const { app, groupRef } = await projectFixture("U1", "张三", "U2", "李四"); + const res = await app.turn( + groupTurn({ externalId: "U1" }, groupRef, "@李四 看这张图", { + attachments: [{ name: "shot.png", mimetype: "image/png", sizeBytes: 3, blobId: "b1" }], + }), + ); + assert.notEqual(res.status, "silent"); + }); + + it("slack-originated group mentions still reach the model", async () => { + const { app, groupRef } = await projectFixture("U1", "张三", "U2", "李四"); + const res = await app.turn({ + ...groupTurn({ externalId: "U1" }, groupRef, "@李四 看下这个方案"), + surface: "slack", + }); + assert.equal(res.status, "ok"); + }); +});