mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 16:44:45 +00:00
fix(twitch): keep account monitor alive until abort (#81853)
Summary:
- Keep Twitch startAccount alive until abort via runStoppablePassiveMonitor.
- Add lifecycle regression coverage and env-gated live Twitch IRC proof.
- Add changelog credit for #60071 / #81853.
Verification:
- pnpm test extensions/twitch/src/plugin.lifecycle.test.ts extensions/twitch/src/plugin.test.ts extensions/twitch/src/twitch-client.test.ts src/gateway/server-channels.test.ts
- pnpm exec oxfmt --check --threads=1 extensions/twitch/src/plugin.ts extensions/twitch/src/plugin.lifecycle.test.ts extensions/twitch/src/plugin.live.test.ts CHANGELOG.md
- pnpm test:live -- extensions/twitch/src/plugin.live.test.ts (skipped without Twitch live credentials)
- codex-review --mode branch --parallel-tests targeted Twitch/gateway tests
- GitHub checks on aea52056c6 green
Fixes #60071.
Co-authored-by: 許元豪 <146086744+edenfunf@users.noreply.github.com>
This commit is contained in:
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Twitch: keep gateway accounts running until shutdown instead of treating successful monitor startup as a clean channel exit, preventing immediate auto-restart loops. Fixes #60071. (#81853) Thanks @edenfunf.
|
||||
- Agents/auto-reply: honor `agents.defaults.silentReply` and per-surface group silent-reply policy when generic agent-run failure fallbacks decide whether to send visible fallback text. Fixes #82060. (#82086) Thanks @taozengabc.
|
||||
- Control UI/WebChat: focus the composer when users click the visible input chrome and restore larger, labeled desktop composer controls while preserving compact mobile taps. Fixes #45656. Thanks @BunsDev.
|
||||
- System events: keep owner downgrades in structured metadata while rendering queued prompt text as plain `System:` lines, preserving least-privilege wakeups without prompt-visible trust labels. (#82067)
|
||||
|
||||
86
extensions/twitch/src/plugin.lifecycle.test.ts
Normal file
86
extensions/twitch/src/plugin.lifecycle.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
createStartAccountContext,
|
||||
expectStopPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TwitchAccountConfig } from "./types.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
monitorTwitchProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", () => ({
|
||||
monitorTwitchProvider: hoisted.monitorTwitchProvider,
|
||||
}));
|
||||
|
||||
const { twitchPlugin } = await import("./plugin.js");
|
||||
|
||||
type TwitchStartAccount = NonNullable<NonNullable<typeof twitchPlugin.gateway>["startAccount"]>;
|
||||
|
||||
function requireStartAccount(): TwitchStartAccount {
|
||||
const startAccount = twitchPlugin.gateway?.startAccount;
|
||||
if (!startAccount) {
|
||||
throw new Error("Expected Twitch gateway startAccount");
|
||||
}
|
||||
return startAccount;
|
||||
}
|
||||
|
||||
function buildAccount(): TwitchAccountConfig & { accountId: string } {
|
||||
return {
|
||||
accountId: "default",
|
||||
username: "testbot",
|
||||
accessToken: "oauth:test-token",
|
||||
clientId: "test-client-id",
|
||||
channel: "#testchannel",
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function mockStartedMonitor() {
|
||||
const stop = vi.fn();
|
||||
hoisted.monitorTwitchProvider.mockResolvedValue({ stop });
|
||||
return stop;
|
||||
}
|
||||
|
||||
function startTwitchAccount(abortSignal?: AbortSignal) {
|
||||
return requireStartAccount()(
|
||||
createStartAccountContext({
|
||||
account: buildAccount(),
|
||||
abortSignal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("twitch startAccount lifecycle", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort, then stops the monitor", async () => {
|
||||
const stop = mockStartedMonitor();
|
||||
const { abort, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: requireStartAccount(),
|
||||
account: buildAccount(),
|
||||
});
|
||||
await expectStopPendingUntilAbort({
|
||||
waitForStarted: waitForStartedMocks(hoisted.monitorTwitchProvider),
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
stop,
|
||||
});
|
||||
});
|
||||
|
||||
it("stops immediately when startAccount receives an already-aborted signal", async () => {
|
||||
const stop = mockStartedMonitor();
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
|
||||
await startTwitchAccount(abort.signal);
|
||||
|
||||
expect(hoisted.monitorTwitchProvider).toHaveBeenCalledOnce();
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
120
extensions/twitch/src/plugin.live.test.ts
Normal file
120
extensions/twitch/src/plugin.live.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Live Twitch IRC verification for the runStoppablePassiveMonitor lifecycle
|
||||
* pattern used by the Twitch gateway.
|
||||
*
|
||||
* This test connects to irc.chat.twitch.tv using the same twurple stack the
|
||||
* Twitch plugin uses, then drives that connection through the helper this PR
|
||||
* wires into twitchPlugin.gateway.startAccount. It asserts the post-fix
|
||||
* invariant — startAccount-shaped task stays pending after a successful
|
||||
* connection and only resolves when the abort signal fires — using real
|
||||
* network rather than mocks.
|
||||
*
|
||||
* Skipped by default. Enable with:
|
||||
* TWITCH_LIVE_TEST=1
|
||||
* TWITCH_USERNAME=<bot username>
|
||||
* TWITCH_ACCESS_TOKEN=<oauth:token without the "oauth:" prefix>
|
||||
* TWITCH_CLIENT_ID=<client id>
|
||||
* TWITCH_CHANNEL=<channel name to join>
|
||||
*/
|
||||
|
||||
import { StaticAuthProvider } from "@twurple/auth";
|
||||
import { ChatClient } from "@twurple/chat";
|
||||
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const LIVE = process.env.TWITCH_LIVE_TEST === "1";
|
||||
const HAS_CREDS = Boolean(
|
||||
process.env.TWITCH_USERNAME &&
|
||||
process.env.TWITCH_ACCESS_TOKEN &&
|
||||
process.env.TWITCH_CLIENT_ID &&
|
||||
process.env.TWITCH_CHANNEL,
|
||||
);
|
||||
|
||||
const maybeDescribe = LIVE && HAS_CREDS ? describe : describe.skip;
|
||||
|
||||
maybeDescribe("twitch live IRC lifecycle (skipped unless TWITCH_LIVE_TEST=1)", () => {
|
||||
it("real twurple connection + runStoppablePassiveMonitor stays pending until abort, then stops cleanly", async () => {
|
||||
const accessTokenRaw = process.env.TWITCH_ACCESS_TOKEN!.replace(/^oauth:/, "");
|
||||
const clientId = process.env.TWITCH_CLIENT_ID!;
|
||||
const channel = process.env.TWITCH_CHANNEL!;
|
||||
const username = process.env.TWITCH_USERNAME!;
|
||||
|
||||
const start = Date.now();
|
||||
const log = (msg: string) => {
|
||||
console.log(`[T+${Date.now() - start}ms] ${msg}`);
|
||||
};
|
||||
|
||||
log(`username=${username} channel=#${channel}`);
|
||||
|
||||
const authProvider = new StaticAuthProvider(clientId, accessTokenRaw, [
|
||||
"chat:read",
|
||||
"chat:edit",
|
||||
]);
|
||||
|
||||
const abort = new AbortController();
|
||||
let connectedAt: number | null = null;
|
||||
let settled = false;
|
||||
let stopCalled = false;
|
||||
|
||||
const task = runStoppablePassiveMonitor({
|
||||
abortSignal: abort.signal,
|
||||
start: async () => {
|
||||
const chat = new ChatClient({
|
||||
authProvider,
|
||||
channels: [channel],
|
||||
authIntents: ["chat"],
|
||||
});
|
||||
|
||||
chat.onConnect(() => {
|
||||
connectedAt = Date.now() - start;
|
||||
log(`Connected to Twitch as ${username}`);
|
||||
});
|
||||
chat.onJoin((joinedChannel: string, joinedUser: string) => {
|
||||
log(`Joined #${joinedChannel} as ${joinedUser}`);
|
||||
});
|
||||
chat.onDisconnect((manually: boolean, reason?: Error) => {
|
||||
log(`Disconnected (manual=${manually}, reason=${reason?.message ?? "n/a"})`);
|
||||
});
|
||||
|
||||
chat.connect();
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
stopCalled = true;
|
||||
log(`stop() invoked`);
|
||||
chat.quit();
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
settled = true;
|
||||
log(`task RESOLVED`);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
settled = true;
|
||||
log(`task REJECTED: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Wait long enough that the original bug would have manifested.
|
||||
// The reported time-to-restart in #60071 is ~2ms after connect.
|
||||
const WATCH_MS = 15_000;
|
||||
await new Promise((resolve) => setTimeout(resolve, WATCH_MS));
|
||||
|
||||
expect(connectedAt, "expected onConnect within the watch window").not.toBeNull();
|
||||
expect(settled, "task must not have settled before abort").toBe(false);
|
||||
log(
|
||||
`--- t+${WATCH_MS}ms checkpoint: connected=${connectedAt}ms, settled=${settled}, stopCalled=${stopCalled}`,
|
||||
);
|
||||
|
||||
abort.abort();
|
||||
log(`abort() called`);
|
||||
|
||||
await task;
|
||||
|
||||
expect(settled).toBe(true);
|
||||
expect(stopCalled, "stop hook must run on abort").toBe(true);
|
||||
log(`PASS — promise pending for ${WATCH_MS}ms after connect, then stopped on abort`);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
createPairingPrefixStripper,
|
||||
} from "openclaw/plugin-sdk/channel-pairing";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import {
|
||||
buildPassiveProbedChannelStatusSummary,
|
||||
runStoppablePassiveMonitor,
|
||||
} from "openclaw/plugin-sdk/extension-shared";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
@@ -180,14 +183,22 @@ export const twitchPlugin: ChannelPlugin<ResolvedTwitchAccount> =
|
||||
|
||||
ctx.log?.info(`Starting Twitch connection for ${account.username}`);
|
||||
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorTwitchProvider } = await import("./monitor.js");
|
||||
await monitorTwitchProvider({
|
||||
account,
|
||||
accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
// Keep startAccount pending until abort fires; otherwise the channel
|
||||
// supervisor reads the settled task as `channel exited without an
|
||||
// error` and triggers a restart loop. See #60071.
|
||||
await runStoppablePassiveMonitor({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () => {
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorTwitchProvider } = await import("./monitor.js");
|
||||
return monitorTwitchProvider({
|
||||
account,
|
||||
accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
stopAccount: async (ctx): Promise<void> => {
|
||||
|
||||
Reference in New Issue
Block a user