mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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:
1 parent
d6c7d30bd4
commit
093f242662
18 files changed
+779
-97
No files matched your search
+179
-1
@@ -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',
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user