Ctrl K
ComunidadGuías…

test/owner-slash.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Slash commands from the owner (server/owner.js, runCommand): "/model opus"
// in the Billion tab or the owner's private Telegram chat is typed bare into
// Billion's terminal, at its prompt only, with no reply awaited; "//" sends a
// literal "/" message; the settled screen comes back into the thread.

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { rmSync } from 'fs';
import { join } from 'path';
import { ownerSays, chatMessages, handleUpdate, pendingOwnerMessages, slashCommand, APP_PREFIX } from '../server/owner.js';
import { dropMessages, flushMessages, sendText, COMMAND_SETTLE_MS } from '../server/messages.js';
import { sessions, CONFIG_DIR } from '../server/state.js';

const ENV = { TELEGRAM_BOT_TOKEN: '123456:SECRET-token', TELEGRAM_CHAT_ID: '42' };
let b;
const typed = () => b.pty.write.mock.calls.map(c => c[0]).join('').replace(/\x1b\[20[01]~/g, '');

beforeEach(() => {
  vi.useFakeTimers({ toFake: ['setTimeout'] });
  vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, result: { message_id: 7 } }) })));
  rmSync(join(CONFIG_DIR, 'chat.json'), { force: true });
  b = {
    id: 'slash-billion', name: 'Billion', isBillion: true, command: 'claude', state: 'WAITING', exited: false,
    ownerId: null, stateChangedAt: Date.now() - 5000, recentStrippedLines: [], isTUI: true, lastOutputAt: 0, pty: { write: vi.fn() },
  };
  sessions.set(b.id, b);
});
afterEach(() => {
  vi.useRealTimers();
  vi.unstubAllGlobals();
  sessions.delete(b.id);
  dropMessages(b.id);
});

describe('routing what the owner types', () => {
  it('knows a command from a message', () => {
    expect(slashCommand('/model opus')).toBe('/model opus');
    for (const t of ['//model', 'hi /model', '/', '/ model', '/model\nopus', '/model\ropus', '/model\x1b[A']) expect(slashCommand(t)).toBeNull();
  });

  it('types a slash command bare, marks it a command, and awaits no reply', async () => {
    expect(await ownerSays('/model opus')).toEqual({ ok: true });
    await vi.advanceTimersByTimeAsync(200);
    expect(typed()).toBe('/model opus\r');
    expect(chatMessages()).toEqual([expect.objectContaining({ from: 'owner', via: 'app', text: '/model opus', command: true })]);
    expect(chatMessages()[0].awaitsReply).toBeUndefined();
    expect(pendingOwnerMessages()).toEqual([]);
  });

  it('sends "//text" as an ordinary message starting with "/"', async () => {
    await ownerSays('//etc/hosts is wrong');
    await vi.advanceTimersByTimeAsync(200);
    expect(typed()).toBe(`${APP_PREFIX} /etc/hosts is wrong\r`);
    expect(chatMessages()[0]).toMatchObject({ text: '/etc/hosts is wrong', awaitsReply: true });
  });

  it('leaves an ordinary message as it was', async () => {
    await ownerSays('model opus please');
    await vi.advanceTimersByTimeAsync(200);
    expect(typed()).toBe(`${APP_PREFIX} model opus please\r`);
  });
});

describe('running it', () => {
  it('waits for Billion to rest at its prompt', async () => {
    b.state = 'WORKING';
    await ownerSays('/model opus');
    await vi.advanceTimersByTimeAsync(200);
    expect(b.pty.write).not.toHaveBeenCalled();
    b.state = 'WAITING';
    b.stateChangedAt = Date.now();
    expect(flushMessages(b)).toBe(true);
    await vi.advanceTimersByTimeAsync(200);
    expect(typed()).toBe('/model opus\r');
  });

  it('shows the settled screen, closes a picker, and holds the next message until then', async () => {
    await ownerSays('/model');
    await vi.advanceTimersByTimeAsync(200);
    b.recentStrippedLines = ['────────', 'Select model', '❯ 1. Default (Opus)', '  2. Sonnet', '────────'];
    sendText(b, 'next', Date.now(), { owner: true });
    b.stateChangedAt = Date.now();
    expect(flushMessages(b)).toBe(false);
    await vi.advanceTimersByTimeAsync(COMMAND_SETTLE_MS);
    expect(chatMessages().at(-1)).toMatchObject({ from: 'billion', screen: true, text: 'Select model\n❯ 1. Default (Opus)\n2. Sonnet' });
    expect(b.pty.write).toHaveBeenLastCalledWith('\x1b');
    expect(pendingOwnerMessages()).toEqual([]);
    b.stateChangedAt = Date.now() + 1;
    expect(flushMessages(b, Date.now() + 2)).toBe(true);
  });

  it('leaves a command that started a turn alone', async () => {
    await ownerSays('/review');
    await vi.advanceTimersByTimeAsync(200);
    b.lastOutputAt = Date.now() + COMMAND_SETTLE_MS;
    await vi.advanceTimersByTimeAsync(COMMAND_SETTLE_MS);
    expect(b.pty.write).not.toHaveBeenCalledWith('\x1b');
    expect(chatMessages().at(-1)).toMatchObject({ screen: true, text: 'Running in Billion\'s terminal.' });
  });
});

describe('from Telegram', () => {
  it('runs a command from the owner\'s private chat and sends the screen back', async () => {
    expect(await handleUpdate({ update_id: 1, message: { chat: { id: 42, type: 'private' }, text: '/model opus' } }, { env: ENV })).toBe('command');
    await vi.advanceTimersByTimeAsync(200);
    expect(typed()).toBe('/model opus\r');
    b.recentStrippedLines = ['Set model to opus'];
    await vi.advanceTimersByTimeAsync(COMMAND_SETTLE_MS);
    expect(fetch.mock.calls.some(([, o]) => String(o?.body).includes('Set model to opus'))).toBe(true);
  });

  it('takes nothing as a command from a group: its members are not the owner', async () => {
    expect(await handleUpdate({ update_id: 2, message: { chat: { id: 42, type: 'group' }, from: { first_name: 'Mallory' }, text: '/model opus' } }, { env: ENV })).toBe('delivered');
    await vi.advanceTimersByTimeAsync(200);
    expect(typed()).toMatch(/^\[Owner via Telegram[^\]]*\] \/model opus\r$/);
  });
});