test(channels): trace-harness follow-ups — scenario names, fault vocabulary, lane sync guard (#104653)

This commit is contained in:
Peter Steinberger
2026-07-11 15:14:02 -07:00
committed by GitHub
parent 7772d798dd
commit 93e5b017bf
10 changed files with 136 additions and 90 deletions
@@ -398,6 +398,9 @@ function setupFeishuTrace(recorder: WireRecorder, scenario: DeliveryTraceScenari
options.onCleanup?.();
break;
case "wire-fault":
if (step.fault !== "rate-limit") {
throw new Error("feishu trace scenarios script only rate-limit wire faults");
}
traceState.wireFaults.push({ fault: step.fault, retryAfterMs: step.retryAfterMs });
break;
}
+45 -44
View File
@@ -17,8 +17,6 @@ import {
runDeliveryTraceScenario,
type DeliveryTraceInStep,
type DeliveryTraceScenario,
type DeliveryTraceScenarioName,
type DeliveryTraceStep,
type WireRecorder,
} from "openclaw/plugin-sdk/channel-contract-testing";
import {
@@ -277,14 +275,6 @@ async function setupMatrixTrace(recorder: WireRecorder) {
};
}
// Matrix records generation-scoped edit-streaming shapes the shared v1
// library does not model; the runner treats the scenario name as opaque data
// (it only keys the golden filename), so widening the closed union stays
// test-local instead of leaking matrix names into the core scenario library.
function matrixScenario(name: string, steps: readonly DeliveryTraceStep[]): DeliveryTraceScenario {
return { name: name as DeliveryTraceScenarioName, steps };
}
const GEN_ONE_PARTIAL = "Build check:";
const GEN_ONE_BLOCK = "Build check: all suites green.";
const GEN_TWO_PARTIAL = "Deploying to production now.";
@@ -297,50 +287,61 @@ const MENTION_FINAL = "Paging @oncall:example.org for the deploy review.";
// The draft stream throttles edits at 1000ms; each generation streams one
// create plus one throttled m.replace edit before its boundary settles it.
// Matrix records generation-scoped edit-streaming shapes the shared v1
// library does not model; the scenario name only keys the golden filename.
const MATRIX_TRACE_SCENARIOS: readonly DeliveryTraceScenario[] = [
// Two generations separated by a tool round: one preview event per
// generation, cumulative slices, block finalize-in-place for generation one,
// and a final replace-edit because the final text differs from the last
// rendered preview of generation two.
matrixScenario("streaming-happy-multi-generation", [
{ kind: "reply-start" },
{ kind: "partial", text: GEN_ONE_PARTIAL },
{ kind: "advance", ms: 400 },
{ kind: "partial", text: GEN_ONE_BLOCK },
{ kind: "advance", ms: 700 },
{ kind: "block-final", text: GEN_ONE_BLOCK },
{ kind: "tool-progress", name: "deploy", phase: "start" },
{ kind: "tool-progress", name: "deploy", phase: "result" },
{ kind: "partial", text: GEN_TWO_PARTIAL },
{ kind: "advance", ms: 400 },
{ kind: "partial", text: GEN_TWO_PREVIEW },
{ kind: "advance", ms: 700 },
{ kind: "final", text: GEN_TWO_FINAL },
{ kind: "idle" },
]),
{
name: "streaming-happy-multi-generation",
steps: [
{ kind: "reply-start" },
{ kind: "partial", text: GEN_ONE_PARTIAL },
{ kind: "advance", ms: 400 },
{ kind: "partial", text: GEN_ONE_BLOCK },
{ kind: "advance", ms: 700 },
{ kind: "block-final", text: GEN_ONE_BLOCK },
{ kind: "tool-progress", name: "deploy", phase: "start" },
{ kind: "tool-progress", name: "deploy", phase: "result" },
{ kind: "partial", text: GEN_TWO_PARTIAL },
{ kind: "advance", ms: 400 },
{ kind: "partial", text: GEN_TWO_PREVIEW },
{ kind: "advance", ms: 700 },
{ kind: "final", text: GEN_TWO_FINAL },
{ kind: "idle" },
],
},
// Final text equals the last rendered preview: the draft is finalized in
// place with an edit that only clears the MSC4357 live marker.
matrixScenario("final-in-place", [
{ kind: "reply-start" },
{ kind: "partial", text: IN_PLACE_PARTIAL },
{ kind: "advance", ms: 400 },
{ kind: "partial", text: IN_PLACE_FINAL },
{ kind: "advance", ms: 700 },
{ kind: "final", text: IN_PLACE_FINAL },
{ kind: "idle" },
]),
{
name: "final-in-place",
steps: [
{ kind: "reply-start" },
{ kind: "partial", text: IN_PLACE_PARTIAL },
{ kind: "advance", ms: 400 },
{ kind: "partial", text: IN_PLACE_FINAL },
{ kind: "advance", ms: 700 },
{ kind: "final", text: IN_PLACE_FINAL },
{ kind: "idle" },
],
},
// Previews are mentions-inert and an edit cannot retro-notify, so a final
// whose text would activate mentions redacts the preview and re-sends the
// final as a fresh mention-bearing event.
matrixScenario("final-mentions-fresh", [
{ kind: "reply-start" },
{ kind: "partial", text: MENTION_PARTIAL },
{ kind: "advance", ms: 400 },
{ kind: "partial", text: MENTION_FINAL },
{ kind: "advance", ms: 700 },
{ kind: "final", text: MENTION_FINAL },
{ kind: "idle" },
]),
{
name: "final-mentions-fresh",
steps: [
{ kind: "reply-start" },
{ kind: "partial", text: MENTION_PARTIAL },
{ kind: "advance", ms: 400 },
{ kind: "partial", text: MENTION_FINAL },
{ kind: "advance", ms: 700 },
{ kind: "final", text: MENTION_FINAL },
{ kind: "idle" },
],
},
// Shared abandon shape: the handler's finally block flushes the pending
// throttled edit, then redacts the unconsumed draft.
deliveryTraceScenarios["cancel-mid-stream"],
@@ -307,6 +307,9 @@ function setupMSTeamsTrace(recorder: WireRecorder, traceCase: MSTeamsTraceCase)
await created.markDispatchIdle();
break;
case "wire-fault":
// The shared write-error fault vocabulary covers this shape, but a
// scripted step records a new IN event and would change the committed
// goldens; write-count arming at setup replays the same wire bytes.
throw new Error("msteams trace scenarios arm stream write faults at setup instead");
}
};
+32 -34
View File
@@ -29,7 +29,6 @@ import {
runDeliveryTraceScenario,
type DeliveryTraceInStep,
type DeliveryTraceScenario,
type DeliveryTraceStep,
type WireRecorder,
} from "openclaw/plugin-sdk/channel-contract-testing";
import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
@@ -384,13 +383,6 @@ function setupQqbotTrace(recorder: WireRecorder, msgId: string) {
};
}
// Channel-specific scenario names are not yet part of the shared
// DeliveryTraceScenarioName union; the runner only consumes steps and the
// golden is keyed by file name, so a local cast keeps the harness untouched.
function qqbotScenario(name: string, steps: readonly DeliveryTraceStep[]): DeliveryTraceScenario {
return { name: name as DeliveryTraceScenario["name"], steps };
}
const MEDIA_INTERRUPT_FULL_TEXT =
"Here is the chart:\n<qqimg>https://example.com/chart.png</qqimg>\nKey takeaways: ship it.";
@@ -398,41 +390,47 @@ const QQBOT_TRACE_SCENARIOS: readonly DeliveryTraceScenario[] = [
// Official REPLACE-mode stream lifecycle over the shared streaming-happy
// script: one stream session, cumulative GENERATING chunks, boundary joined
// with "\n\n", DONE chunk sealing the full text.
qqbotScenario("streaming-happy-c2c", deliveryTraceScenarios["streaming-happy"].steps),
{ name: "streaming-happy-c2c", steps: deliveryTraceScenarios["streaming-happy"].steps },
// Budget lifecycle against one msg_id: initial input_notify plus exactly
// TYPING_RENEWAL_LIMIT (3) renewals, then a renewal-free tick proving the
// reserved final reply; five message-tool sends where only the first can
// claim the fifth passive slot; then a static final. Every later text send
// falls back to a proactive body without msg_id/msg_seq.
qqbotScenario("budget-exhaustion", [
{ kind: "reply-start" },
{ kind: "advance", ms: 5000 },
{ kind: "advance", ms: 5000 },
{ kind: "advance", ms: 5000 },
{ kind: "advance", ms: 5000 },
{ kind: "tool-progress", name: "message", phase: "result" },
{ kind: "tool-progress", name: "message", phase: "result" },
{ kind: "tool-progress", name: "message", phase: "result" },
{ kind: "tool-progress", name: "message", phase: "result" },
{ kind: "tool-progress", name: "message", phase: "result" },
{ kind: "final", text: "Budget check complete." },
{ kind: "idle" },
]),
qqbotScenario("final-only", deliveryTraceScenarios["final-only"].steps),
qqbotScenario("cancel-mid-stream", deliveryTraceScenarios["cancel-mid-stream"].steps),
{
name: "budget-exhaustion",
steps: [
{ kind: "reply-start" },
{ kind: "advance", ms: 5000 },
{ kind: "advance", ms: 5000 },
{ kind: "advance", ms: 5000 },
{ kind: "advance", ms: 5000 },
{ kind: "tool-progress", name: "message", phase: "result" },
{ kind: "tool-progress", name: "message", phase: "result" },
{ kind: "tool-progress", name: "message", phase: "result" },
{ kind: "tool-progress", name: "message", phase: "result" },
{ kind: "tool-progress", name: "message", phase: "result" },
{ kind: "final", text: "Budget check complete." },
{ kind: "idle" },
],
},
deliveryTraceScenarios["final-only"],
deliveryTraceScenarios["cancel-mid-stream"],
// Media arriving mid-stream interrupts the session: DONE chunk for the text
// before the tag, synchronous upload + media message (both budget spends),
// then a fresh stream session with a new stream_msg_id and msg_seq resumes
// the remaining text.
qqbotScenario("media-interrupt", [
{ kind: "reply-start" },
{ kind: "partial", text: "Here is the chart:" },
{ kind: "advance", ms: 300 },
{ kind: "partial", text: MEDIA_INTERRUPT_FULL_TEXT },
{ kind: "advance", ms: 300 },
{ kind: "final", text: MEDIA_INTERRUPT_FULL_TEXT },
{ kind: "idle" },
]),
{
name: "media-interrupt",
steps: [
{ kind: "reply-start" },
{ kind: "partial", text: "Here is the chart:" },
{ kind: "advance", ms: 300 },
{ kind: "partial", text: MEDIA_INTERRUPT_FULL_TEXT },
{ kind: "advance", ms: 300 },
{ kind: "final", text: MEDIA_INTERRUPT_FULL_TEXT },
{ kind: "idle" },
],
},
];
const EXPECTED_REPLY_BUDGET_REMAINING: Readonly<Record<string, number>> = {
+3 -8
View File
@@ -15,7 +15,6 @@ import {
expectDeliveryTraceMatchesGolden,
runDeliveryTraceScenario,
type DeliveryTraceInStep,
type DeliveryTraceScenario,
type DeliveryTraceStep,
type TraceEvent,
type TraceNormalizer,
@@ -215,8 +214,8 @@ const BLOCKS_FINAL_PRESENTATION = {
],
};
// Slack-specific scenario scripts. The runner only consumes `steps`, so local
// names outside the shared scenario-library union are safe behind one cast.
// Slack-specific scenario scripts; the runner only consumes `steps` and the
// name (outside the shared scenario library) keys the golden filename.
const slackTraceScenarios: Record<SlackTraceScenarioName, readonly DeliveryTraceStep[]> = {
"streaming-happy-native": [
{ kind: "reply-start" },
@@ -271,10 +270,6 @@ const slackTraceScenarios: Record<SlackTraceScenarioName, readonly DeliveryTrace
],
};
function asTraceScenario(name: SlackTraceScenarioName): DeliveryTraceScenario {
return { name, steps: slackTraceScenarios[name] } as unknown as DeliveryTraceScenario;
}
/** Canonicalizes Slack `sec.micro` timestamps to `ts#N` in first-seen order. */
function createSlackTsNormalizer(): TraceNormalizer {
const seen = new Map<string, string>();
@@ -624,7 +619,7 @@ describe("slack delivery trace goldens", () => {
for (const scenarioName of Object.keys(slackTraceScenarios) as SlackTraceScenarioName[]) {
it(`records ${scenarioName}`, async () => {
const events = await runDeliveryTraceScenario({
scenario: asTraceScenario(scenarioName),
scenario: { name: scenarioName, steps: slackTraceScenarios[scenarioName] },
setup: (recorder) => setupSlackTrace(recorder, scenarioName),
normalize: createSlackTsNormalizer(),
});
@@ -292,6 +292,9 @@ async function setupTelegramTrace(recorder: WireRecorder) {
// An aborted run stops emitting payloads; teardown happens on idle.
break;
case "wire-fault":
if (step.fault !== "rate-limit") {
throw new Error("telegram trace scenarios script only rate-limit wire faults");
}
state.wireFaults.push({ retryAfterMs: step.retryAfterMs });
armedRetryAfterMs = step.retryAfterMs;
break;
+1
View File
@@ -40,6 +40,7 @@ export type ChangedTestTargetPlan = {
export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS: string;
export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_HEARTBEAT_MS: string;
export const CHANNEL_CONTRACT_CONFIG_PATTERNS: ReadonlyMap<string, readonly string[]>;
export function orderFullSuiteSpecsForParallelRun<T extends { config: string }>(
specs: T[],
+5 -1
View File
@@ -2337,7 +2337,11 @@ const RUNNABLE_VITEST_CONFIG_TARGETS = new Set([
...Object.values(VITEST_CONFIG_BY_KIND),
...fullSuiteVitestShards.flatMap((shard) => [shard.config, ...shard.projects]),
]);
const CHANNEL_CONTRACT_CONFIG_PATTERNS = new Map([
// Duplicates the per-config pattern arrays in test/vitest/vitest.contracts-shared.ts
// because this file must stay loader-free plain JS. Drift silently drops
// contract files from lane routing, so test/scripts/test-projects.test.ts
// asserts both enumerations stay identical.
export const CHANNEL_CONTRACT_CONFIG_PATTERNS = new Map([
[
CONTRACTS_CHANNEL_SURFACE_VITEST_CONFIG,
[
@@ -54,7 +54,15 @@ export type TraceEvent = {
/** Canonicalizes volatile fields (ids, timestamps) before compare/write. */
export type TraceNormalizer = (event: TraceEvent) => TraceEvent;
/** Agent-side lifecycle steps a channel maps onto its dispatcher wiring. */
/**
* Agent-side lifecycle steps a channel maps onto its dispatcher wiring.
*
* `wire-fault` declares mocked wire-client misbehavior in the script instead
* of hand-armed mock state in setup: `rate-limit` scripts a retryable
* 429-shaped rejection honored after `retryAfterMs`; `write-error` makes the
* next and every later wire write throw an error whose `name` is `errorName`
* (e.g. the msteams SDK's StreamCancelledError on user Stop).
*/
export type DeliveryTraceInStep =
| { kind: "reply-start" }
| { kind: "partial"; text: string }
@@ -63,15 +71,23 @@ export type DeliveryTraceInStep =
| { kind: "final"; text?: string; mediaUrls?: string[]; isError?: boolean }
| { kind: "cancel" }
| { kind: "idle" }
| { kind: "wire-fault"; fault: "rate-limit"; retryAfterMs: number };
| { kind: "wire-fault"; fault: "rate-limit"; retryAfterMs: number }
| { kind: "wire-fault"; fault: "write-error"; errorName: string };
export type DeliveryTraceStep = DeliveryTraceInStep | { kind: "advance"; ms: number };
export type DeliveryTraceScenario = {
name: DeliveryTraceScenarioName;
/**
* Golden filename key. Shared-library scenarios use the closed
* DeliveryTraceScenarioName union; channels may record channel-specific
* scenarios under their own names. `string & {}` keeps completion for the
* shared names without closing the set — the runner never branches on it.
*/
name: DeliveryTraceScenarioName | (string & {});
steps: readonly DeliveryTraceStep[];
};
/** Names in the shared scenario library (`deliveryTraceScenarios`). */
export type DeliveryTraceScenarioName =
| "streaming-happy"
| "final-only"
+22
View File
@@ -6,6 +6,7 @@ import path from "node:path";
import fg from "fast-glob";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
CHANNEL_CONTRACT_CONFIG_PATTERNS,
DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_HEARTBEAT_MS,
DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS,
applyDefaultMultiSpecVitestCachePaths,
@@ -29,6 +30,12 @@ import {
} from "../../scripts/test-projects.test-support.mjs";
import { captureReaddirSyncCallsDuring } from "../../src/test-utils/fs-scan-assertions.js";
import { toRepoPath } from "../../src/test-utils/repo-files.js";
import {
channelConfigContractPatterns,
channelRegistryContractPatterns,
channelSessionContractPatterns,
channelSurfaceContractPatterns,
} from "../vitest/vitest.contracts-shared.ts";
import { fullSuiteVitestShards } from "../vitest/vitest.test-shards.mjs";
const normalizeRepoPath = toRepoPath;
@@ -4890,3 +4897,18 @@ describe("scripts/test-projects Vitest cache isolation", () => {
expect(applyDefaultMultiSpecVitestCachePaths(watch, { cwd: "/repo", env: {} })).toBe(watch);
});
});
describe("scripts/test-projects channel contract lane patterns", () => {
// test-projects.test-support.mjs must stay loader-free plain JS, so it
// duplicates the per-config channel-contract patterns instead of importing
// vitest.contracts-shared.ts. Drift silently drops contract files from lane
// routing (it happened once), so pin both enumerations to each other.
it("stays in sync with the vitest.contracts-shared lane enumerations", () => {
expect(Object.fromEntries(CHANNEL_CONTRACT_CONFIG_PATTERNS)).toEqual({
"test/vitest/vitest.contracts-channel-surface.config.ts": channelSurfaceContractPatterns,
"test/vitest/vitest.contracts-channel-config.config.ts": channelConfigContractPatterns,
"test/vitest/vitest.contracts-channel-registry.config.ts": channelRegistryContractPatterns,
"test/vitest/vitest.contracts-channel-session.config.ts": channelSessionContractPatterns,
});
});
});