Skip to content

Commit 94b9fb8

Browse files
committed
fix(discord): single-source thread-binding default placement and guard artifact parity
1 parent 585914f commit 94b9fb8

8 files changed

Lines changed: 83 additions & 6 deletions

File tree

extensions/discord/src/channel.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import { discordSetupAdapter } from "./setup-adapter.js";
8383
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
8484
import { collectDiscordStatusIssues } from "./status-issues.js";
8585
import { parseDiscordTarget } from "./target-parsing.js";
86+
import { defaultTopLevelPlacement } from "./thread-binding-api.js";
8687

8788
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
8889
const discordMessageAdapter = createChannelMessageAdapterFromOutbound({
@@ -479,7 +480,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
479480
},
480481
conversationBindings: {
481482
supportsCurrentConversationBinding: true,
482-
defaultTopLevelPlacement: "child",
483+
defaultTopLevelPlacement,
483484
createManager: async ({ cfg, accountId }) =>
484485
(await loadDiscordThreadBindingsManagerModule()).createThreadBindingManager({
485486
cfg,
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Discord API module exposes the plugin public contract.
2+
// Single source for the top-level artifact and the runtime plugin so the
3+
// fast-path placement hint cannot drift from conversationBindings.
4+
export const defaultTopLevelPlacement = "child" as const;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
// Discord API module exposes the plugin public contract.
2-
export const defaultTopLevelPlacement = "child" as const;
2+
export { defaultTopLevelPlacement } from "./src/thread-binding-api.js";

extensions/matrix/src/channel.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ import {
7979
} from "./setup-contract.js";
8080
import { createMatrixSetupWizardProxy, matrixSetupAdapter } from "./setup-core.js";
8181
import { runMatrixStartupMaintenance } from "./startup-maintenance.js";
82-
import { resolveMatrixInboundConversation } from "./thread-binding-api.js";
82+
import {
83+
defaultTopLevelPlacement,
84+
resolveMatrixInboundConversation,
85+
} from "./thread-binding-api.js";
8386
import type { CoreConfig } from "./types.js";
8487
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
8588
let matrixStartupLock: Promise<void> = Promise.resolve();
@@ -454,7 +457,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
454457
},
455458
conversationBindings: {
456459
supportsCurrentConversationBinding: true,
457-
defaultTopLevelPlacement: "child",
460+
defaultTopLevelPlacement,
458461
setIdleTimeoutBySessionKey: ({ targetSessionKey, accountId, idleTimeoutMs }) =>
459462
setMatrixThreadBindingIdleTimeoutBySessionKey({
460463
targetSessionKey,

scripts/test-projects.test-support.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2384,6 +2384,7 @@ const CHANNEL_CONTRACT_CONFIG_PATTERNS = new Map([
23842384
"src/channels/plugins/contracts/plugins-core.resolve-config-writes.contract.test.ts",
23852385
"src/channels/plugins/contracts/registry.contract.test.ts",
23862386
"src/channels/plugins/contracts/session-binding.registry-backed.contract.test.ts",
2387+
"src/channels/plugins/contracts/thread-binding-artifact.contract.test.ts",
23872388
"src/channels/plugins/contracts/*-shard-d.contract.test.ts",
23882389
"src/channels/plugins/contracts/*-shard-h.contract.test.ts",
23892390
],
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Thread-binding artifact parity contract for bundled channel plugins.
2+
//
3+
// Core resolves default thread placement from lightweight `thread-binding-api`
4+
// artifacts before full plugin loading (src/channels/plugins/thread-binding-api.ts).
5+
// This suite pins artifact exports to the runtime plugin's conversationBindings
6+
// so the fast path cannot drift from loaded-plugin behavior.
7+
import { beforeAll, describe, expect, it } from "vitest";
8+
import { loadBundledPluginPublicSurface } from "../../../test-utils/bundled-plugin-public-surface.js";
9+
import {
10+
getBundledChannelPluginAsync,
11+
listBundledChannelPluginIds,
12+
} from "./test-helpers/bundled-channel-plugin-loader.js";
13+
14+
// Bundled channels expected to ship a top-level thread-binding artifact.
15+
const THREAD_BINDING_ARTIFACT_PLUGIN_IDS = ["discord", "matrix"] as const;
16+
17+
const MISSING_PUBLIC_SURFACE_PREFIX = "Unable to resolve bundled plugin public surface ";
18+
19+
type ThreadBindingArtifactModule = { defaultTopLevelPlacement?: unknown };
20+
21+
async function loadThreadBindingArtifact(
22+
pluginId: string,
23+
): Promise<ThreadBindingArtifactModule | null> {
24+
try {
25+
return await loadBundledPluginPublicSurface<ThreadBindingArtifactModule>({
26+
pluginId,
27+
artifactBasename: "thread-binding-api.js",
28+
});
29+
} catch (error) {
30+
if (error instanceof Error && error.message.startsWith(MISSING_PUBLIC_SURFACE_PREFIX)) {
31+
return null;
32+
}
33+
throw error;
34+
}
35+
}
36+
37+
describe("bundled channel thread-binding artifact parity", () => {
38+
const artifactPlacements = new Map<string, unknown>();
39+
40+
beforeAll(async () => {
41+
for (const id of listBundledChannelPluginIds()) {
42+
const artifact = await loadThreadBindingArtifact(id);
43+
if (artifact) {
44+
artifactPlacements.set(id, artifact.defaultTopLevelPlacement);
45+
}
46+
}
47+
});
48+
49+
it("keeps the artifact table in sync with bundled channels that ship one", () => {
50+
expect([...artifactPlacements.keys()].toSorted()).toEqual([
51+
...THREAD_BINDING_ARTIFACT_PLUGIN_IDS,
52+
]);
53+
});
54+
55+
it.each(THREAD_BINDING_ARTIFACT_PLUGIN_IDS)(
56+
"keeps the %s artifact placement equal to the runtime plugin default",
57+
async (id) => {
58+
const artifactPlacement = artifactPlacements.get(id);
59+
expect(["current", "child"]).toContain(artifactPlacement);
60+
61+
const plugin = await getBundledChannelPluginAsync(id);
62+
expect(plugin?.conversationBindings?.defaultTopLevelPlacement).toBe(artifactPlacement);
63+
},
64+
);
65+
});

src/channels/plugins/thread-binding-api.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,12 @@ describe("bundled channel thread binding fast path", () => {
6868
});
6969

7070
it("treats missing artifacts as absent hints", () => {
71-
expect(resolveBundledChannelThreadBindingDefaultPlacement("discord")).toBeUndefined();
71+
// "absent" is a synthetic channel; real bundled artifacts are covered by
72+
// the thread-binding artifact parity contract test.
73+
expect(resolveBundledChannelThreadBindingDefaultPlacement("absent")).toBeUndefined();
7274
expect(
7375
resolveBundledChannelThreadBindingInboundConversation({
74-
channelId: "discord",
76+
channelId: "absent",
7577
to: "channel:general",
7678
isGroup: true,
7779
}),

test/vitest/vitest.contracts-shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const channelSessionContractPatterns = [
3636
"src/channels/plugins/contracts/plugins-core.resolve-config-writes.contract.test.ts",
3737
"src/channels/plugins/contracts/registry.contract.test.ts",
3838
"src/channels/plugins/contracts/session-binding.registry-backed.contract.test.ts",
39+
"src/channels/plugins/contracts/thread-binding-artifact.contract.test.ts",
3940
"src/channels/plugins/contracts/*-shard-d.contract.test.ts",
4041
"src/channels/plugins/contracts/*-shard-h.contract.test.ts",
4142
];

0 commit comments

Comments
 (0)