feat(discord): post a finished turn as the CLI's whole last message, stale thread cleanup, types moved to src/types

This commit is contained in:
Devvyn committed 2026-10-30 18:26:35 +08:00
1 parent d6c7d30bd4
commit 093f242662
18 files changed
+779 -97

No files matched your search

+179 -1
View File
@@ -16,6 +16,7 @@ import {
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DiscordConfig } from '../src/types/discord.js';
import {
buildPost,
DISCORD_STARTUP_HINT,
@@ -23,6 +24,7 @@ import {
isDiscordCdnUrl,
safeAttachmentName,
screenTail,
splitForDiscord,
DEFAULT_DISCORD_CONFIG,
DiscordApi,
DiscordBridge,
@@ -36,7 +38,6 @@ import {
shouldPing,
threadNameFor,
writeDiscordConfig,
type DiscordConfig,
type DiscordFetch,
type DiscordMessage,
type GatewaySocket,
@@ -557,6 +558,129 @@ describe('DiscordBridge', () => {
b.stop();
});
describe('full last message', () => {
const LONG = Array.from({ length: 60 }, (_, i) => `**Point ${i}**: ${'detail '.repeat(10)}\nIs this right?`).join(
'\n\n'
);
it('posts the whole message as markdown: head and first part together, the rest without a ping', async () => {
const b = bridge();
await b.notify({ ...MSG, tail: 'screen chrome', fullText: async () => LONG });
const sent = posts.filter((p) => p.path.endsWith('/messages'));
expect(sent.length).toBeGreaterThan(1);
const first = String(sent[0].body?.content);
expect(first).toContain(MSG.title);
expect(first).toContain('**Point 0**');
expect(first).not.toContain('screen chrome');
expect(first).not.toContain('```'); // markdown, not a code box
expect(sent[1].body?.allowed_mentions).toEqual({ parse: [], users: [] });
const all = sent.map((p) => String(p.body?.content)).join('\n');
expect(all.match(/Is this right\?/g)).toHaveLength(60);
b.stop();
});
it('does not post the same message twice (idle, then stop), and falls back to the tail without a transcript', async () => {
const b = bridge();
await b.notify({ ...MSG, fullText: async () => 'Short answer.' });
t += 10_000;
posts.length = 0;
await b.notify({ ...MSG, event: 'hook:stop', tail: 'chrome', fullText: async () => 'Short answer.' });
const again = posts.filter((p) => p.path.endsWith('/messages')).map((p) => String(p.body?.content));
expect(again).toHaveLength(1);
expect(again[0]).not.toContain('Short answer.');
expect(again[0]).not.toContain('chrome');
t += 10_000;
posts.length = 0;
await b.notify({ ...MSG, event: 'hook:idle_prompt', tail: 'the screen', fullText: async () => undefined });
expect(String(posts.find((p) => p.path.endsWith('/messages'))?.body?.content)).toContain('the screen');
b.stop();
});
it('falls back to the tail when reading the transcript throws or hangs', async () => {
const b = bridge();
await b.notify({
...MSG,
tail: 'the screen',
fullText: async () => {
throw new Error('no transcript');
},
});
expect(String(posts.find((p) => p.path.endsWith('/messages'))?.body?.content)).toContain('the screen');
b.stop();
});
});
describe('sweepStaleThreads', () => {
const ME = '555555555555555555';
const OTHER = '666666666666666666';
const OLD = (n: number) => `30000000000000000${n}`; // a snowflake from 2015, long before `now`
let young: string;
let calls: Array<{ method: string; path: string; body?: unknown }>;
async function swept(staleThreads: 'keep' | 'archive' | 'delete') {
await writeDiscordConfig(dir, { ...CFG, staleThreads });
t = Date.now();
young = String((BigInt(t - 60_000 - 1420070400000) << 22n) + 5n);
calls = [];
const th = (id: string, owner = ME, parent = PARENT) => ({ id, owner_id: owner, parent_id: parent });
const b = bridge({}, async (url, init) => {
const path = new URL(String(url)).pathname.replace('/api/v10', '');
const method = String(init.method);
calls.push({ method, path, body: init.body ? JSON.parse(String(init.body)) : undefined });
if (path === '/users/@me') return jsonRes({ id: ME });
if (path.endsWith('/threads/active'))
return jsonRes({
threads: [
th(THREAD), // mapped
th(OLD(1)), // stale
th(OLD(2), OTHER), // someone else's thread
th(OLD(3), ME, '999999999999999999'), // another channel
th(young), // too new to judge
],
});
if (path.includes('/threads/archived')) return jsonRes({ threads: [th(OLD(4))] });
if (path.endsWith('/threads')) return jsonRes({ id: THREAD });
return method === 'PUT' ? new Response(null, { status: 204 }) : jsonRes({ id: 'msg' });
});
return b.notify(MSG).then(() => {
calls.length = 0;
return b;
});
}
const touched = () => calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.path.split('/').pop()}`);
it('does nothing while set to keep', async () => {
const b = await swept('keep');
expect(await b.sweepStaleThreads()).toBe(0);
expect(calls).toHaveLength(0);
b.stop();
});
it("archives only this bot's old, unmapped threads in the parent channel, and skips archived ones", async () => {
const b = await swept('archive');
expect(await b.sweepStaleThreads()).toBe(1);
expect(touched()).toEqual([`PATCH ${OLD(1)}`]);
expect(calls.some((c) => c.path.includes('/threads/archived'))).toBe(false);
b.stop();
});
it('deletes stale active and archived threads, and still spares the rest', async () => {
const b = await swept('delete');
expect(await b.sweepStaleThreads()).toBe(2);
expect(touched()).toEqual([`DELETE ${OLD(1)}`, `DELETE ${OLD(4)}`]);
b.stop();
});
it('does nothing when the map is empty, since a lost map would make every thread look stale', async () => {
const b = await swept('delete');
await b.sessionDeleted('s1'); // empties the map
calls.length = 0;
expect(await b.sweepStaleThreads()).toBe(0);
expect(calls).toHaveLength(0);
b.stop();
});
});
it('keeps the gateway off while held (boot) and starts it on release', async () => {
const b = bridge();
b.hold();
@@ -1028,7 +1152,61 @@ describe('DiscordBridge files and startup hint', () => {
});
});
describe('splitForDiscord', () => {
it('keeps a short message whole', () => {
expect(splitForDiscord('Line one\n\n**Two**\n- a\n- b')).toEqual(['Line one\n\n**Two**\n- a\n- b']);
});
it('splits a long message into posts under the limit and loses no line', () => {
const lines = Array.from({ length: 120 }, (_, i) => `- item ${i} ${'x'.repeat(40)}`);
const posts = splitForDiscord(lines.join('\n'));
expect(posts.length).toBeGreaterThan(1);
expect(posts.every((p) => p.length <= 1900)).toBe(true);
expect(
posts
.join('\n')
.split('\n')
.filter((l) => l.startsWith('- item'))
).toEqual(lines);
});
it('closes a code fence the cut lands inside and reopens it in the next post', () => {
const body = ['```', ...Array.from({ length: 100 }, (_, i) => `row ${i} ${'y'.repeat(40)}`), '```'].join('\n');
const posts = splitForDiscord(body);
expect(posts.length).toBeGreaterThan(1);
for (const p of posts) expect((p.match(/^```/gm) ?? []).length % 2).toBe(0);
});
it('cuts one enormous line by length, and caps the number of posts with a note', () => {
const posts = splitForDiscord('z'.repeat(50_000), 1900, 3);
expect(posts).toHaveLength(3);
expect(posts.every((p) => p.length <= 2000)).toBe(true); // Discord's own limit, with the note included
expect(posts[2]).toMatch(/continues in the tab/);
});
});
describe('screenTail', () => {
it("cuts the composer row and the status lines under it, but keeps a dialog's selected option", () => {
const idle = [
" I'll sync the results and update the docs when the run finishes.",
'✻ Crunched for 7m 55s · done 4:22 PM',
'✔ Update installed · /restart to apply',
'❯',
' Sonnet 5.5 in:169,629 out:751 ctx:17% cache:until 17:22',
' ⏵⏵ bypass permissions on (shift+tab to cycle)',
].join('\n');
expect(screenTail(idle, 14, 900, '❯')).toBe(
[
" I'll sync the results and update the docs when the run finishes.",
'✻ Crunched for 7m 55s · done 4:22 PM',
'✔ Update installed · /restart to apply',
].join('\n')
);
const dialog = ['Do you want to proceed?', '❯ 1. Yes', ' 2. No', 'Esc to cancel'].join('\n');
expect(screenTail(dialog, 14, 900, '❯')).toBe(dialog);
expect(screenTail(idle)).toContain('❯'); // no glyph given: nothing is cut
});
it('keeps the last lines of a rendered screen and drops rules, blanks and escape codes', () => {
const screen = [
'old line',
+41
View File
@@ -22,6 +22,7 @@ type Internals = {
discordBridge: Record<string, ReturnType<typeof vi.fn>>;
sessions: Map<string, unknown>;
mux: { capturePaneText?: (name: string) => string | null };
lastResponseFor?: (session: unknown, context?: string) => Promise<{ text: string }>;
sendPushNotifications: (event: string, data: Record<string, unknown>) => Promise<void>;
broadcast: (event: string, data: unknown) => void;
cleanupSession: (id: string, killMux?: boolean, reason?: string) => Promise<void>;
@@ -88,6 +89,46 @@ describe('sendPushNotifications -> Discord', () => {
expect(sendNotification).not.toHaveBeenCalled();
});
it('reads the whole last message from the transcript on a finished turn, and keeps the screen for a dialog', async () => {
const s = server();
s.sessions.set('s5', { muxName: 'codeman-s5', textOutput: '', mode: 'claude' });
const reader = vi.fn(async () => ({ text: 'The full answer.\n\n- one\n- two' }));
s.lastResponseFor = reader;
s.mux = { capturePaneText: () => 'screen\n❯\n status' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's5', sessionName: 'w5' });
const idle = s.discordBridge.notify.mock.calls[0][0];
expect(await idle.fullText()).toBe('The full answer.\n\n- one\n- two');
expect(idle.tail()).toBe('screen'); // the composer row and the status under it are cut
await s.sendPushNotifications('hook:permission_prompt', { sessionId: 's5', sessionName: 'w5' });
const dialog = s.discordBridge.notify.mock.calls[1][0];
expect(dialog.fullText).toBeUndefined();
expect(dialog.tail()).toBe('screen\n❯\n status');
});
it.each(['claude', 'codex', 'opencode', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot'])(
'a finished turn in a %s tab posts the whole last message',
async (mode) => {
const s = server();
s.sessions.set('s6', { muxName: 'codeman-s6', textOutput: '', mode });
s.lastResponseFor = vi.fn(async () => ({ text: '1. First?\n2. Second?\n3. Third?' }));
s.mux = { capturePaneText: () => 'screen' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's6', sessionName: 'w6' });
const msg = s.discordBridge.notify.mock.calls[0][0];
expect(await msg.fullText()).toBe('1. First?\n2. Second?\n3. Third?');
}
);
it('a shell tab has no turns, so it keeps the screen tail', async () => {
const s = server();
s.sessions.set('s7', { muxName: 'codeman-s7', textOutput: '', mode: 'shell' });
s.lastResponseFor = vi.fn(async () => ({ text: 'x' }));
s.mux = { capturePaneText: () => 'prompt$' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's7', sessionName: 'w7' });
const msg = s.discordBridge.notify.mock.calls[0][0];
expect(await msg.fullText()).toBeUndefined();
expect(msg.tail()).toBe('prompt$');
});
it('falls back to plain text output when there is no tmux pane, and to no tail for an unknown session', async () => {
const s = server();
s.sessions.set('s2', { muxName: null, textOutput: 'plain answer\n', mode: 'claude' });
@@ -0,0 +1,196 @@
/**
* @fileoverview The last message a CLI wrote, read through the response viewer's reader (the one the
* Discord bridge posts from), with a message that asks SEVERAL questions: every question must come
* back whole, in order, on its own line, and survive the split into Discord posts.
*
* Uses app.inject() with a temporary HOME and CODEX_HOME; no real ports or user transcripts.
* Port: N/A
*
* Covered here: claude (JSONL transcript), codex (rollout), the codex-shaped pane CLIs (opencode,
* gemini, antigravity, pi) and copilot (its own pane dialect). deepseek and omp read their own
* transcript formats and are covered by their reader's tests.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { splitForDiscord } from '../../src/discord-bridge.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
const QUESTIONS = [
'Should the sweep run daily or hourly?',
'Do you want archived threads deleted as well?',
'Is one channel per Codeman acceptable?',
];
const INTRO = 'I have three questions before I continue:';
const OUTRO = 'Once you answer, I will apply the change.';
/** The message as the CLI wrote it: an intro, a numbered list of questions, an outro. */
const MESSAGE = [INTRO, '', ...QUESTIONS.map((q, i) => `${i + 1}. ${q}`), '', OUTRO].join('\n');
/** Every question present, in order, each on a line of its own. */
function expectWholeQuestions(text: string): void {
let from = 0;
for (const q of QUESTIONS) {
const at = text.indexOf(q, from);
expect(at, `missing or out of order: ${q}`).toBeGreaterThanOrEqual(from);
from = at + q.length;
}
const lines = text.split('\n').map((l) => l.trim());
for (const [i, q] of QUESTIONS.entries()) expect(lines.some((l) => l.endsWith(q))).toBe(true);
expect(text).toContain(INTRO);
expect(text).toContain(OUTRO);
// What goes to Discord: the same questions after the split, joined back.
expectWholeQuestionsAfterSplit(text);
}
function expectWholeQuestionsAfterSplit(text: string): void {
const joined = splitForDiscord(text).join('\n');
for (const q of QUESTIONS) expect(joined).toContain(q);
}
async function createHarness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext }> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
app.addHook('preSerialization', (req, _reply, payload: unknown, done) => {
if (!req.url.startsWith('/api') || payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown };
return done(null, p.success === undefined ? { success: true, data: payload } : payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('the last message with several questions, per CLI', () => {
let harness: { app: FastifyInstance; ctx: MockRouteContext };
let home: string;
let prevHome: string | undefined;
let prevCodexHome: string | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let session: any;
async function lastText(): Promise<string> {
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${session.id}/last-response` });
return JSON.parse(res.body).data.text as string;
}
beforeEach(async () => {
home = mkdtempSync(join(tmpdir(), 'codeman-discord-last-'));
prevHome = process.env.HOME;
prevCodexHome = process.env.CODEX_HOME;
process.env.HOME = home;
process.env.CODEX_HOME = join(home, '.codex');
harness = await createHarness();
session = harness.ctx._session;
});
afterEach(async () => {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevCodexHome;
rmSync(home, { recursive: true, force: true });
await harness.app.close();
});
it('claude: reads the whole message from the JSONL transcript', async () => {
const id = '40568a29-d4eb-4eb6-b671-8401428e4f39';
session.mode = 'claude';
session.claudeSessionId = id;
session.adoptClaudeSessionId = () => undefined;
const dir = join(home, '.claude', 'projects', '-workspace');
mkdirSync(dir, { recursive: true });
const row = (type: string, text: string, ts: string) => ({
type,
timestamp: ts,
message: { content: [{ type: 'text', text }] },
});
writeFileSync(
join(dir, `${id}.jsonl`),
[row('user', 'plan the cleanup', '2026-10-30T00:00:00Z'), row('assistant', MESSAGE, '2026-10-30T00:00:01Z')]
.map((e) => JSON.stringify(e))
.join('\n')
);
const text = await lastText();
expect(text).toBe(MESSAGE);
expectWholeQuestions(text);
});
it('codex: reads the whole message from the rollout', async () => {
const uuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const workdir = join(home, 'work');
mkdirSync(workdir, { recursive: true });
session.mode = 'codex';
session.workingDir = workdir;
session.codexConfig = { resumeSessionId: uuid };
const dir = join(home, '.codex', 'sessions', '2026', '10', '30');
mkdirSync(dir, { recursive: true });
const file = join(dir, `rollout-2026-10-30T00-00-00-${uuid}.jsonl`);
const entries = [
{
timestamp: '2026-10-30T00:00:00Z',
type: 'session_meta',
payload: { id: uuid, cwd: workdir, originator: 'codex_cli_rs' },
},
{
timestamp: '2026-10-30T00:00:01Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'plan the cleanup' },
},
{
timestamp: '2026-10-30T00:00:02Z',
type: 'response_item',
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: MESSAGE }] },
},
];
writeFileSync(file, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
utimesSync(file, 1_750_000_000, 1_750_000_000);
const text = await lastText();
expectWholeQuestions(text);
});
for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) {
it(`${mode}: reads the whole message from the pane`, async () => {
session.mode = mode;
session.terminalBuffer = [
'› plan the cleanup',
'',
'model · project · main · Ready · Context 100% left',
'',
'• Called write_file',
'',
MESSAGE,
].join('\n');
expectWholeQuestions(await lastText());
});
}
it('copilot: reads the whole message from the pane screen', async () => {
session.mode = 'copilot';
const screen = [
' ❯ plan the cleanup 10:01',
'',
` ● ${INTRO}`,
'',
...QUESTIONS.map((q, i) => ` ${i + 1}. ${q}`),
'',
` ${OUTRO}`,
'',
' ~/project Session: 1 AIC used',
'────────────────────────────────',
' ❯ ',
'────────────────────────────────',
' ← open sidebar · / commands · ? help',
].join('\n');
harness.ctx.mux = { ...(harness.ctx.mux as object), capturePaneText: () => screen } as typeof harness.ctx.mux;
expectWholeQuestions(await lastText());
});
});
+6 -2
View File
@@ -72,9 +72,13 @@ describe('/api/discord', () => {
it('keeps the token when only an option changes, and clears it on an empty string', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
await app.inject({ method: 'PUT', url: '/api/discord', payload: { pingMode: 'all', onSessionDelete: 'delete' } });
await app.inject({
method: 'PUT',
url: '/api/discord',
payload: { pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' },
});
const cfg = await readDiscordConfig(dir);
expect(cfg).toMatchObject({ token: TOKEN, pingMode: 'all', onSessionDelete: 'delete' });
expect(cfg).toMatchObject({ token: TOKEN, pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' });
const cleared = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: false, token: '' } });
expect(cleared.json().data.hasToken).toBe(false);
bridge.stop();