fix(discord): say when the agent has exited, keep replies in order, settle a stalled response, prune dead tabs

- a reply to a tab whose agent has exited is answered in the thread, with no tick
- inbound messages run on one chain per thread, so a reply with a file cannot be overtaken
- postJson settles when a response stalls after its headers
- the thread map is pruned against the live sessions once after boot
This commit is contained in:
Devvyn committed 2026-10-30 10:33:34 +08:00
1 parent 72a59de96c
commit b68792f988
6 files changed
+108 -4

No files matched your search

+15
View File
@@ -71,6 +71,21 @@ describe('runNotify', () => {
}
});
it('postJson settles when the server sends headers and then stalls the body', async () => {
const server = createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '500' });
res.write('{"success":');
});
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
try {
const { port } = server.address() as AddressInfo;
expect((await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100)).status).toBe(0);
} finally {
server.closeAllConnections();
server.close();
}
});
it('treats a disabled bridge (409) as success that says so', async () => {
const r = await runNotify({ message: 'x' }, ENV, '/work', fake(409));
expect(r.code).toBe(0);
+34 -1
View File
@@ -551,6 +551,17 @@ describe('DiscordBridge', () => {
b.stop();
});
it('prune drops thread entries for tabs that no longer exist, and keeps the live ones', async () => {
const b = bridge();
await b.notify(MSG);
await b.notify({ ...MSG, sessionId: 's2', sessionName: 'w2' });
await b.prune(['s2']);
expect(Object.keys(await readThreadMap(dir))).toEqual(['s2']);
await b.prune([]); // nothing restored: leave the map alone
expect(Object.keys(await readThreadMap(dir))).toEqual(['s2']);
b.stop();
});
describe('sendTest', () => {
it('posts with the config it is given and leaves the live config alone', async () => {
const auth: string[] = [];
@@ -734,7 +745,8 @@ describe('DiscordBridge files and startup hint', () => {
function make(
cfg: Partial<DiscordConfig> = {},
dirFor: (id: string) => string | undefined = () => work,
awaitingChoice?: (id: string) => boolean
awaitingChoice?: (id: string) => boolean,
agentExited?: (id: string) => boolean
) {
const b = new DiscordBridge({
configDir: dir,
@@ -751,6 +763,7 @@ describe('DiscordBridge files and startup hint', () => {
sendInput: async (id, text, fromUser) => (sent.push([id, text, fromUser]), true),
sessionDir: dirFor,
awaitingChoice,
agentExited,
socketFactory: () => new FakeSocket(),
now: () => t,
sleep: async () => undefined,
@@ -834,6 +847,26 @@ describe('DiscordBridge files and startup hint', () => {
b.stop();
});
it('says the agent has exited and types nothing when the tab reports it', async () => {
const b = await make({}, undefined, undefined, () => true);
await b.onGatewayMessage(reply({ content: 'hello', attachments: [] }));
expect(sent).toHaveLength(0);
expect(String(posted.at(-1)?.body?.content)).toMatch(/exited/);
expect(posted.some((p) => p.path.includes('reactions'))).toBe(false);
b.stop();
});
it('types a reply that waits on a download before a later text reply', async () => {
const b = await make();
const slow = b.onGatewayMessage(reply({ content: 'first' }));
await new Promise((r) => setTimeout(r, 0)); // the first reply is under way (gate passed) before the clock moves
t += 5_000;
const fast = b.onGatewayMessage(reply({ id: '555555555555555556', content: 'second', attachments: [] }));
await Promise.all([slow, fast]);
expect(sent.map((s) => s[1].split(' ')[0])).toEqual(['first', 'second\r']);
b.stop();
});
it('types a lone digit without Enter while a dialog is waiting, and with Enter otherwise', async () => {
let waiting = true;
const b = await make({}, undefined, () => waiting);
+14
View File
@@ -43,6 +43,20 @@ function server(): Internals {
beforeEach(() => sendNotification.mockClear());
describe('bridge deps', () => {
it('agentExited is true only for a session whose pane exit was observed', () => {
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals & {
discordBridge: { deps: { agentExited: (id: string) => boolean } };
};
s.sessions.set('dead', { paneExit: { observedAt: 1 } });
s.sessions.set('alive', {});
const { agentExited } = s.discordBridge.deps;
expect(agentExited('dead')).toBe(true);
expect(agentExited('alive')).toBe(false);
expect(agentExited('missing')).toBe(false);
});
});
describe('sendPushNotifications -> Discord', () => {
it('posts a permission prompt with zero push subscriptions, with the rendered screen as the tail', async () => {
const s = server();