From 9dd3ae72adcdfcc0b160e0589428309d25a3d78f Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 2 Sep 2026 09:51:47 +0700 Subject: [PATCH 1/2] feat(engine): language-drift flag on subagent result envelopes (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit glm-5.3-flash drifted into Chinese for all/part of the final report in 4/7 dispatches (even with explicit English-only prompt instructions) — findings were sound, but the controller had no cheap signal and had to eyeball every verdict line. - new src/engine/language-drift.ts: pure CJK-family letter-ratio detector (Han/Hiragana/Katakana/Hangul >= 30% of letters, min 40 letters; tunable constants; quoted-CJK-inside-English stays clean; Latin non-English deliberately out of scope — spec N3) - finishRun computes it on every terminal path (both backends) and journals languageDrift/languageDriftRatio on run:ended (additive — post-hoc diagnosability per the #59/#60/#61 pattern) - tool result prefixed with a one-line warning on drifted runs (mirrors the #61 zero-tool prefix; composes with it) + details fields - README 'Model-quality drift signals' section (also documents the #89 reviewer-reference guidance for controllers) - spec: docs/superpowers/specs/2026-09-02-spec-language-drift-flag.md - +11 tests (detector x8, wiring x3); 835/835 --- README.md | 7 ++ .../2026-09-02-spec-language-drift-flag.md | 69 +++++++++++++++++++ src/engine/language-drift.ts | 40 +++++++++++ src/engine/spawnSubagent.ts | 19 ++++- src/runtime/run-log.ts | 4 ++ src/tools/subagent.ts | 16 ++++- test/language-drift.test.mts | 57 +++++++++++++++ test/subagent-tool.test.mts | 57 +++++++++++++++ 8 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/specs/2026-09-02-spec-language-drift-flag.md create mode 100644 src/engine/language-drift.ts create mode 100644 test/language-drift.test.mts diff --git a/README.md b/README.md index 1a9ba9b..1536c13 100644 --- a/README.md +++ b/README.md @@ -454,6 +454,13 @@ Four fixes from dogfooding the fleet on itself (issues #58–#61): - **Zero-tool-call flag (#61)** — a run that "completes" without a single executed tool call (the premature-return shape: narrate a plan, end) is prefixed with `[FLEET] zero-tool-call run — likely a premature return` in the tool result; `details.toolCallCount` exposes the count. Verify (git status/log) before trusting such a result. - **Richer run journal** — `run:ended` now carries `error` (failure reason), `filesTouched` (#49 parity in the durable journal — real SDK args are captured from `tool_execution_start`; the end event has none), and `toolCallCount`. +## Model-quality drift signals (v1.2.0) + +Cheap, additive signals for the output-quality drift observed on flat/cheap model tiers (issues #88/#89). Both are **flagging, not blocking** — findings were sound every time; the controller decides. + +- **Language-drift flag (#88)** — when a completed run's final report is majority CJK-family script (Han/Hiragana/Katakana/Hangul ≥ 30% of letters, min 40 letters), the tool result is prefixed with `[FLEET] language drift — final report is N% CJK-family script (#88)…` and `details.languageDrift`/`languageDriftRatio` are set. Observed: glm-5.3-flash drifted into Chinese in 4/7 dispatches, *even with an explicit English-only instruction in the prompt*. Quoted CJK content inside an English report stays clean; `run:ended` journals the flag for post-hoc diagnosis. Latin-script non-English (fr/de/…) is deliberately not detected (see the spec's non-goals). +- **Reviewer cross-domain references (#89, guidance)** — reviewer subagents on cheap tiers sometimes fabricate plausible-sounding *process* references (briefs, roll-ups, sibling-repo fixes) while their verdicts and code findings remain verifiable. Rule for controllers: **treat any reviewer reference to an artifact outside the provided brief/report/diff as suspect — verify findings by git + file contents, never by report prose.** The fleet's structured result fields (`details.*`) and the journal are the trustworthy surface; the narrative tail is not. + ## Roadmap armory-fleet follows a PRD → SPEC-N (brainstorm → spec → plan → implementation) pipeline. **16/16 phases done through v0.12.0.** diff --git a/docs/superpowers/specs/2026-09-02-spec-language-drift-flag.md b/docs/superpowers/specs/2026-09-02-spec-language-drift-flag.md new file mode 100644 index 0000000..8d962a3 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-spec-language-drift-flag.md @@ -0,0 +1,69 @@ +# Spec: #88 — language-drift flag in subagent result envelopes + +**Date:** 2026-09-02 · **Status:** approved (RECTOR: "do you rec, full power") · **Issue:** #88 + +## Problem + +On the 2026-08-31 armory-gateway SDD run, `zai/glm-5.3-flash` drifted into Chinese for all +or part of the final report in **4 of 7 dispatches** — including two whose dispatch prompt +contained an explicit "RESPOND IN ENGLISH ONLY" instruction. Findings were sound every +time; the friction is that the controller has **no cheap signal** and must eyeball every +verdict line. The drift family (CJK) is specific to the model tier the fleet steers people +to (flat z.ai/GLM plans). + +## Goals + +- G1: controller gets a machine-readable flag on every dispatch whose final report is + majority CJK-family script — without reading the report. +- G2: flag is **additive, surfacing-only** (the #61 precedent): never blocks, never + rewrites, never re-dispatches on its own. +- G3: survives the journal — a drifted run is diagnosable post-hoc from `run:ended` + (the #59/#60/#61 pattern: terminal facts land in the journal, not just the result). +- G4: zero config by default; no new required settings, no dispatch-param surface yet. + +## Non-goals + +- N1: **Not** auto re-dispatch / auto-translate (controller's call). +- N2: **Not** the `resultLanguage` system-side preference (issue's "optional") — + dispatch-prompt placement demonstrably isn't enough and a system-prompt edit changes + every child's substrate; re-file standalone if wanted. +- N3: **Not** Latin-script non-English detection (fr, de, …): legitimate quoted content + would false-positive, and the observed drift family is CJK. Scope is documented in the + detector. + +## Design decisions + +- **D1 — signal: CJK-family letter ratio over the final message.** + `detectLanguageDrift(text)` counts Han/Hiragana/Katakana/Hangul codepoints over the + letter population (Latin + Latin-extended + CJK family). Flags when ratio ≥ 0.3 AND + letters ≥ 40 (short strings can't triage). Constants exported + tunable. + Alternatives rejected: full language-ID library (dependency weight for a heuristic); + any-non-ASCII (false-positives on quotes/emoji/CJK identifiers). +- **D2 — detection site: `finishRun`** (single composition point for every terminal path — + direct, fallback retry, turn-budget; both backends since it reads `finalText`). + Computed once, placed on the SpawnResult + journaled. +- **D3 — SpawnResult surface:** `languageDrift?: boolean` + `languageDriftRatio?: number` + (ratio gives severity; boolean is the greppable flag the issue asked for). + `run:ended` gains `languageDrift?: boolean` + `languageDriftRatio?: number` + (**additive** — the frozen RPC/journal surface allows additive fields; pinned tests + updated, no renames). +- **D4 — tool output: one-line warning prefix** (mirrors #61's zero-tool prefix): + `[FLEET] language drift — final report is N% CJK-family script (#88); findings may + still be sound — re-dispatch or translate if the controller requires English.` + Only on non-error results (a failed run's error text needs no drift triage). + Details expose both fields. + +## Wiring + +1. `src/engine/language-drift.ts` (new): pure detector + constants. +2. `src/engine/spawnSubagent.ts` `finishRun`: compute, return on both paths, journal. +3. `src/runtime/run-log.ts` `RunEndedEvent`: additive optional fields. +4. `src/tools/subagent.ts`: warning prefix + `details`. + +## Test plan + +- Pure detector: English no-drift; pure-CJK drift; ~50% mixed drift; Korean/Japanese in + family; short-text floor; empty string. +- Tool wiring: drifted child → prefix + details fields; English child → no prefix, + `languageDrift` undefined; journaled `run:ended` carries the flag (RunLog replay). +- All env-independent (no providers, tmpdir only). diff --git a/src/engine/language-drift.ts b/src/engine/language-drift.ts new file mode 100644 index 0000000..0156e7f --- /dev/null +++ b/src/engine/language-drift.ts @@ -0,0 +1,40 @@ +// src/engine/language-drift.ts +// #88: cheap post-run signal for non-English drift in subagent final reports. Observed: +// zai/glm-5.3-flash drifted into Chinese for all or part of the report in 4/7 dispatches +// — even with an explicit English-only instruction in the dispatch prompt. Findings were +// sound every time, so this is FLAGGING, not blocking: the controller decides whether to +// re-dispatch or translate. Spec: docs/superpowers/specs/2026-09-02-spec-language-drift-flag.md +// +// Scope (deliberate): CJK-family scripts (Han / Hiragana / Katakana / Hangul) — the drift +// family actually observed on the model tiers the fleet steers people to. Quoted CJK +// content inside an otherwise-English report stays well under the threshold; a +// majority-CJK report trips it. Latin-script non-English (fr/de/…) is NOT detected — see +// spec N3. + +export interface LanguageDrift { + /** true when the CJK-family letter ratio crossed DRIFT_RATIO (and MIN_LETTERS was met). */ + drift: boolean; + /** CJK-family letters / total letters (0 when the text has no letters). */ + ratio: number; +} + +/** CJK-family: Hiragana+Katakana (3040–30FF), CJK ext-A (3400–4DBF), CJK Unified + * (4E00–9FFF), Hangul syllables (AC00–D7AF), CJK compat ideographs (F900–FAFF). */ +const CJK_RE = /[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/g; +/** Letter population: ASCII + Latin-extended + the CJK family (denominator for the ratio). */ +const LETTER_RE = /[A-Za-z\u00C0-\u024F\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/g; + +/** Ratio at or above which a report is considered drifted. 0.3 ≈ a third of the letters — + * comfortably above any plausible quoting noise, well below the ~0.5 mixed-report case. */ +export const DRIFT_RATIO = 0.3; + +/** Reports shorter than this can't be triaged (a one-line verdict quoting '通过' must not flag). */ +export const MIN_LETTERS = 40; + +export function detectLanguageDrift(text: string): LanguageDrift { + const cjk = (text.match(CJK_RE) ?? []).length; + const letters = (text.match(LETTER_RE) ?? []).length; + if (letters === 0) return { drift: false, ratio: 0 }; + const ratio = cjk / letters; + return { drift: letters >= MIN_LETTERS && ratio >= DRIFT_RATIO, ratio }; +} diff --git a/src/engine/spawnSubagent.ts b/src/engine/spawnSubagent.ts index f53705b..231aa29 100644 --- a/src/engine/spawnSubagent.ts +++ b/src/engine/spawnSubagent.ts @@ -13,6 +13,7 @@ import type { RunLog } from "../runtime/run-log.ts"; import { buildToolEvent } from "../runtime/run-log.ts"; import { resolveAgentModel, type ModelRegistryLike } from "../tiers/resolve.ts"; import { TierRegistry } from "../tiers/tier-registry.ts"; +import { detectLanguageDrift } from "./language-drift.ts"; const PI_DEFAULT_TOOLS = ["read", "bash", "edit", "write"]; @@ -201,6 +202,11 @@ export interface SpawnResult { /** #61: number of executed tool calls. A "completed" run with 0 is the premature-return * shape (the child narrated and ended without acting) — the tool flags it in the result. */ toolCallCount?: number; + /** #88: CJK-family drift flag on the final report (flagging, not blocking — the controller + * decides whether to re-dispatch/translate). Undefined when clean or too short to triage. */ + languageDrift?: boolean; + /** #88: CJK-family letter ratio that produced the flag (severity, 0–1). */ + languageDriftRatio?: number; } /** #49: extract file paths a tool event touched, for the structured partial-result report. @@ -639,6 +645,12 @@ async function finishRun(a: FinishRunArgs): Promise { const contextTokens = a.contextTokens ?? 0; const toolCallCount = a.toolCallCount ?? 0; const { retryable, filesTouched, reachedSummary } = a; + // #88: cheap CJK-family drift signal over the final report — flagging, not blocking + // (spec: docs/superpowers/specs/2026-09-02-spec-language-drift-flag.md). Undefined when + // clean so the 99% case keeps result + journal unchanged. + const drift = detectLanguageDrift(finalText); + const languageDrift = drift.drift ? true : undefined; + const languageDriftRatio = drift.drift ? drift.ratio : undefined; if (finalizedRunIds.has(runId)) { // Already finalized — return the existing registry record's result without re-appending. const existing = opts.runRegistry.get(runId); @@ -647,7 +659,7 @@ async function finishRun(a: FinishRunArgs): Promise { runId, todoId, agent: agentName, model, durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt, tokenTotal, costTotal, contextTokens, error, retryable, - filesTouched, reachedSummary, toolCallCount, + filesTouched, reachedSummary, toolCallCount, languageDrift, languageDriftRatio, }; } finalizedRunIds.add(runId); @@ -671,6 +683,9 @@ async function finishRun(a: FinishRunArgs): Promise { toolCallCount, // #60: what the run mutated (was only on the SpawnResult; the durable journal lacked it). filesTouched, + // #88: drift flag + ratio on the final report (post-hoc diagnosability). + languageDrift, + languageDriftRatio, }); } catch { /* best-effort: journal is the index, not the product */ } // SPEC-4: lifecycle phase children skip the per-run todo reconciliation — the lifecycle @@ -689,6 +704,6 @@ async function finishRun(a: FinishRunArgs): Promise { return { status, finalText, runId, todoId, agent: agentName, model, durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error, retryable, - filesTouched, reachedSummary, toolCallCount, + filesTouched, reachedSummary, toolCallCount, languageDrift, languageDriftRatio, }; } diff --git a/src/runtime/run-log.ts b/src/runtime/run-log.ts index d357931..510e433 100644 --- a/src/runtime/run-log.ts +++ b/src/runtime/run-log.ts @@ -41,6 +41,10 @@ export interface RunEndedEvent { toolCallCount?: number; /** #60: file paths the run mutated (post-hoc — was previously SpawnResult-only). */ filesTouched?: string[]; + /** #88: CJK-family drift flag on the final report (flagging, not blocking). */ + languageDrift?: boolean; + /** #88: CJK-family letter ratio that produced the flag (severity, 0–1). */ + languageDriftRatio?: number; } export type RunLogEvent = RunMetaEvent | MessageEvent | ToolEvent | RunEndedEvent; diff --git a/src/tools/subagent.ts b/src/tools/subagent.ts index de6cce7..aa11409 100644 --- a/src/tools/subagent.ts +++ b/src/tools/subagent.ts @@ -262,10 +262,20 @@ export function createSubagentTool(deps: SubagentToolDeps) { // (the child narrated a plan and ended without acting) — flag it in-band so the controller // verifies (git status/log) instead of trusting a terse planning statement as a completion. const zeroToolRun = !isError && (finalRes.toolCallCount ?? 0) === 0; + // #88: CJK-family drift in the final report — flag in-band so the controller can + // re-dispatch or translate without eyeballing every verdict line (spec: docs/superpowers/ + // specs/2026-09-02-spec-language-drift-flag.md). Findings may still be sound. + const driftWarning = !isError && finalRes.languageDrift + ? `[FLEET] language drift — final report is ${Math.round((finalRes.languageDriftRatio ?? 0) * 100)}% CJK-family script (#88); findings may still be sound — re-dispatch or translate if the controller requires English.` + : null; + const zeroToolWarning = zeroToolRun + ? `[FLEET] zero-tool-call run — likely a premature return (#61); verify with git status/log before trusting this result.` + : null; + const warnings = [zeroToolWarning, driftWarning].filter((w): w is string => w !== null); const resultText = isError ? (finalRes.error ?? finalRes.status) - : zeroToolRun - ? `[FLEET] zero-tool-call run — likely a premature return (#61); verify with git status/log before trusting this result.\n\n${finalRes.finalText}` + : warnings.length > 0 + ? `${warnings.join("\n\n")}\n\n${finalRes.finalText}` : finalRes.finalText; return { content: [{ type: "text" as const, text: resultText }], @@ -275,6 +285,8 @@ export function createSubagentTool(deps: SubagentToolDeps) { retriedWithModel, filesTouched: finalRes.filesTouched, reachedSummary: finalRes.reachedSummary, toolCallCount: finalRes.toolCallCount, // #61: zero = the premature-return signal + languageDrift: finalRes.languageDrift, // #88: CJK-family drift flag + languageDriftRatio: finalRes.languageDriftRatio, }, isError, }; diff --git a/test/language-drift.test.mts b/test/language-drift.test.mts new file mode 100644 index 0000000..9af5137 --- /dev/null +++ b/test/language-drift.test.mts @@ -0,0 +1,57 @@ +// test/language-drift.test.mts — #88: pure detector for CJK-family drift in final reports. +// Spec: docs/superpowers/specs/2026-09-02-spec-language-drift-flag.md +import { test } from "node:test"; +import { strictEqual } from "node:assert"; +import { detectLanguageDrift, DRIFT_RATIO, MIN_LETTERS } from "../src/engine/language-drift.ts"; + +test("#88: English report → no drift, ratio 0", () => { + const r = detectLanguageDrift("The review is complete. All 5 findings verified against the diff; verdict: ship."); + strictEqual(r.drift, false); + strictEqual(r.ratio, 0); +}); + +test("#88: pure-CJK report → drift, ratio 1", () => { + const r = detectLanguageDrift("审查已完成。所有五个发现均已针对差异进行验证。结论:通过。代码质量良好,没有明显的问题,可以合并这条分支。"); + strictEqual(r.drift, true); + strictEqual(r.ratio, 1); +}); + +test("#88: half-Chinese mixed report → drift (the observed 4/7 failure mode)", () => { + // English verdict line + Chinese analysis body — the drift was "all or part". + const mixed = + "Verdict: ship with nits. " + + "审查已完成。所有五个发现均已针对差异进行验证,代码质量良好,没有明显的问题需要修复。"; + const r = detectLanguageDrift(mixed); + strictEqual(r.drift, true, "mixed report with a CJK-majority body flags"); +}); + +test("#88: mostly-English with a few CJK quotes → no drift (quoted content must not trip it)", () => { + const r = detectLanguageDrift( + "The helper validateUser in src/auth.ts rejects empty names. The Chinese error message '用户名不能为空' is quoted verbatim from the fixture. Everything else checks out fine.", + ); + strictEqual(r.drift, false, "short quoted CJK inside an English report stays clean"); +}); + +test("#88: Hangul is in the drift family", () => { + const r = detectLanguageDrift("리뷰가 완료되었습니다. 모든 다섯 가지 발견이 차이점에 대해 검증되었으며 코드 품질이 좋습니다."); + strictEqual(r.drift, true); +}); + +test("#88: Kana is in the drift family", () => { + const r = detectLanguageDrift("レビューが完了しました。すべての五つの発見が差分に対して検証されており、コードの品質は良好です。"); + strictEqual(r.drift, true); +}); + +test("#88: short text under MIN_LETTERS → never flags (can't triage)", () => { + const r = detectLanguageDrift("通过。"); + strictEqual(r.drift, false, "below the letter floor"); + strictEqual(r.ratio, 1, "ratio still reported honestly"); + strictEqual(typeof MIN_LETTERS, "number"); + strictEqual(typeof DRIFT_RATIO, "number"); +}); + +test("#88: empty text → no drift, ratio 0", () => { + const r = detectLanguageDrift(""); + strictEqual(r.drift, false); + strictEqual(r.ratio, 0); +}); diff --git a/test/subagent-tool.test.mts b/test/subagent-tool.test.mts index bd572d7..bd399ce 100644 --- a/test/subagent-tool.test.mts +++ b/test/subagent-tool.test.mts @@ -640,3 +640,60 @@ test("#62: scheduled dispatch with cwd stores it on the schedule", async () => { rmSync(plain, { recursive: true, force: true }); rmSync(childCwd, { recursive: true, force: true }); }); + +// --- #88: language-drift flag (spec: docs/superpowers/specs/2026-09-02-spec-language-drift-flag.md) --- + +test("#88: CJK-majority final report → warning prefix + details fields", async () => { + const driftHandlers: Array<(e: any) => void> = []; + const driftChild = { + prompt: async () => { + for (const h of driftHandlers) { + h({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "审查已完成。所有五个发现均已针对差异进行验证,代码质量良好,没有明显的问题需要修复,可以合并。" }] } }); + } + }, + subscribe: (h: any) => { driftHandlers.push(h); return () => {}; }, abort: async () => {}, dispose: () => {}, + }; + const deps = makeDeps(); + deps.backendRegistry = regWith({ create: async () => ({ session: driftChild, model: "zai/glm-5.3-flash" }) }); + const tool = createSubagentTool(deps); + const out = await tool.execute!("c", { agent: "g", task: "review the diff" } as any, new AbortController().signal, () => {}, {} as any); + strictEqual((out.details as any).status, "completed"); + strictEqual((out.details as any).languageDrift, true, "details expose the drift flag"); + ok(typeof (out.details as any).languageDriftRatio === "number", "details expose the ratio"); + const text = (out.content as any)[0].text as string; + ok(text.includes("[FLEET] language drift"), `warning prefix present: ${text.slice(0, 120)}`); + ok(text.includes("CJK-family"), `names the script family: ${text.slice(0, 160)}`); + ok(text.includes("可以合并"), `original report preserved after the warning: ${text.slice(-60)}`); +}); + +test("#88: English report → no prefix, languageDrift undefined", async () => { + const deps = makeDeps(); // default fakeFactory child reports "done" + const tool = createSubagentTool(deps); + const out = await tool.execute!("c", { agent: "g", task: "hi" } as any, new AbortController().signal, () => {}, {} as any); + strictEqual((out.details as any).languageDrift, undefined, "no drift flag on clean English"); + const text = (out.content as any)[0].text as string; + ok(!text.includes("[FLEET] language drift"), `no prefix: ${text.slice(0, 80)}`); +}); + +test("#88: drifted run journals languageDrift on run:ended (post-hoc diagnosability)", async () => { + const driftHandlers: Array<(e: any) => void> = []; + const driftChild = { + prompt: async () => { + for (const h of driftHandlers) { + h({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "审查已完成。所有五个发现均已针对差异进行验证,代码质量良好,没有明显的问题需要修复,可以合并。" }] } }); + } + }, + subscribe: (h: any) => { driftHandlers.push(h); return () => {}; }, abort: async () => {}, dispose: () => {}, + }; + const deps = makeDeps(); + const log = new (await import("../src/runtime/run-log.ts")).RunLog(tmpDir); + (deps as any).runLog = log; + deps.backendRegistry = regWith({ create: async () => ({ session: driftChild, model: "zai/glm-5.3-flash" }) }); + const tool = createSubagentTool(deps); + const out = await tool.execute!("c", { agent: "g", task: "review the diff" } as any, new AbortController().signal, () => {}, {} as any); + const runId = (out.details as any).runId as string; + const ended = log.replay(runId).find((e) => e.type === "run:ended") as any; + ok(ended, "run:ended journaled"); + strictEqual(ended.languageDrift, true, "journal carries the drift flag"); + ok(typeof ended.languageDriftRatio === "number", "journal carries the ratio"); +}); From 4a72deb3d426a033e3f2b9889260c077b9330ab1 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 2 Sep 2026 09:59:05 +0700 Subject: [PATCH 2/2] feat(rpc): forward languageDrift fields on fleet:run:ended (#88 review NITs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIT-1: journal↔live-bus parity — the conditional-spread allowlists in event-bus + rpc-server observe now forward languageDrift/languageDriftRatio (clean runs stay key-free). Pinned by 2 new tests (live + replay). NIT-2: detector comment documents the deliberately-uncounted Unicode blocks (plane-2 Han ext, Hangul Jamo, halfwidth kana). 837/837. --- src/engine/language-drift.ts | 5 ++++- src/rpc/event-bus.ts | 2 ++ src/rpc/rpc-server.ts | 4 +++- test/fleet-event-bus.test.mts | 19 +++++++++++++++++++ test/rpc-server.test.mts | 13 +++++++++++++ 5 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/engine/language-drift.ts b/src/engine/language-drift.ts index 0156e7f..4b1c956 100644 --- a/src/engine/language-drift.ts +++ b/src/engine/language-drift.ts @@ -19,7 +19,10 @@ export interface LanguageDrift { } /** CJK-family: Hiragana+Katakana (3040–30FF), CJK ext-A (3400–4DBF), CJK Unified - * (4E00–9FFF), Hangul syllables (AC00–D7AF), CJK compat ideographs (F900–FAFF). */ + * (4E00–9FFF), Hangul syllables (AC00–D7AF), CJK compat ideographs (F900–FAFF). + * (#88 review NIT: plane-2 Han extensions, Hangul Jamo, and halfwidth katakana are + * deliberately not counted — never observed in model final reports; extend if a + * real case appears.) */ const CJK_RE = /[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/g; /** Letter population: ASCII + Latin-extended + the CJK family (denominator for the ratio). */ const LETTER_RE = /[A-Za-z\u00C0-\u024F\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/g; diff --git a/src/rpc/event-bus.ts b/src/rpc/event-bus.ts index 0af1d15..e23c7c5 100644 --- a/src/rpc/event-bus.ts +++ b/src/rpc/event-bus.ts @@ -86,6 +86,8 @@ export class FleetEventBus { ...(e.error !== undefined ? { error: e.error } : {}), ...(e.filesTouched !== undefined ? { filesTouched: e.filesTouched } : {}), ...(e.toolCallCount !== undefined ? { toolCallCount: e.toolCallCount } : {}), + ...(e.languageDrift !== undefined ? { languageDrift: e.languageDrift } : {}), + ...(e.languageDriftRatio !== undefined ? { languageDriftRatio: e.languageDriftRatio } : {}), ...(start !== undefined ? { durationMs: e.endedAt - start } : {}), }); } diff --git a/src/rpc/rpc-server.ts b/src/rpc/rpc-server.ts index 8c79c6b..69f0995 100644 --- a/src/rpc/rpc-server.ts +++ b/src/rpc/rpc-server.ts @@ -224,7 +224,9 @@ export class RpcServer { ...(e.resultSummary !== undefined ? { result: e.resultSummary } : {}), ...(e.error !== undefined ? { error: e.error } : {}), ...(e.filesTouched !== undefined ? { filesTouched: e.filesTouched } : {}), - ...(e.toolCallCount !== undefined ? { toolCallCount: e.toolCallCount } : {}) }, + ...(e.toolCallCount !== undefined ? { toolCallCount: e.toolCallCount } : {}), + ...(e.languageDrift !== undefined ? { languageDrift: e.languageDrift } : {}), + ...(e.languageDriftRatio !== undefined ? { languageDriftRatio: e.languageDriftRatio } : {}) }, }); } }); diff --git a/test/fleet-event-bus.test.mts b/test/fleet-event-bus.test.mts index 16ae8e3..d1c5bef 100644 --- a/test/fleet-event-bus.test.mts +++ b/test/fleet-event-bus.test.mts @@ -112,3 +112,22 @@ test("a throwing emit() never breaks the append path", () => { bus.dispose(); } finally { rmSync(dir, { recursive: true, force: true }); } }); + +test("#88: languageDrift fields forward on fleet:run:ended when set; absent when clean", () => { + const h = harness(); + try { + h.runLog.append("fl-d", { type: "run:meta", runId: "fl-d", agent: "scout", model: "m", task: "t", startedAt: 1000, track: true, todoId: null }); + h.runLog.append("fl-d", { type: "run:ended", runId: "fl-d", status: "completed", endedAt: 1500, tokenTotal: 3, languageDrift: true, languageDriftRatio: 0.62 }); + let last = h.emitted[h.emitted.length - 1]!; + assert.equal(last.channel, "fleet:run:ended"); + assert.equal(last.payload.languageDrift, true, "drift flag forwards on the live bus"); + assert.equal(last.payload.languageDriftRatio, 0.62, "ratio forwards on the live bus"); + + h.runLog.append("fl-d2", { type: "run:meta", runId: "fl-d2", agent: "scout", model: "m", task: "t", startedAt: 2000, track: true, todoId: null }); + h.runLog.append("fl-d2", { type: "run:ended", runId: "fl-d2", status: "completed", endedAt: 2500, tokenTotal: 3 }); + last = h.emitted[h.emitted.length - 1]!; + assert.equal(last.channel, "fleet:run:ended"); + assert.equal("languageDrift" in last.payload, false, "clean run payload stays clean (no key)"); + assert.equal("languageDriftRatio" in last.payload, false, "clean run ratio stays absent (no key)"); + } finally { h.bus.dispose(); rmSync(h.dir, { recursive: true, force: true }); } +}); diff --git a/test/rpc-server.test.mts b/test/rpc-server.test.mts index 66cbbc7..a195ea2 100644 --- a/test/rpc-server.test.mts +++ b/test/rpc-server.test.mts @@ -389,3 +389,16 @@ test("#84: exactly-at-LIST_CAP → truncated absent (boundary pin, review NIT 5) assert.equal(r2.data.truncated, 1, "one past the cap → truncated: 1"); } finally { rmSync(h.dir, { recursive: true, force: true }); } }); + +test("#88: observe replay forwards languageDrift fields on fleet:run:ended", async () => { + const h = harness(); + try { + h.runLog.append("fl-dr", { type: "run:meta", runId: "fl-dr", agent: "scout", model: "m", task: "t", startedAt: 1, track: false, todoId: null }); + h.runLog.append("fl-dr", { type: "run:ended", runId: "fl-dr", status: "completed", endedAt: 9, tokenTotal: 3, languageDrift: true, languageDriftRatio: 0.55 }); + const life = await h.server.handle({ id: "od1", verb: "observe", params: { runId: "fl-dr", tier: "lifecycle" } }) as { ok: true; data: { events: Array<{ channel: string; payload: Record }> } }; + const ended = life.data.events.find((e) => e.channel === "fleet:run:ended"); + assert.ok(ended, "run:ended replayed"); + assert.equal(ended!.payload.languageDrift, true); + assert.equal(ended!.payload.languageDriftRatio, 0.55); + } finally { rmSync(h.dir, { recursive: true, force: true }); } +});