Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions cli/__tests__/inbox-join-priority.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/**
* inbox-join-priority.test.mjs — TASK-289
*
* A freshly connected seat takes a turn of its own for the `first_contact`
* event. Measured on the published 0.1.108 Mac smoke (walk 5): the join turn
* ran 01:44:09–01:44:23, a person's @mention landed at 01:44:10 while it was
* running, and the answer came from the NEXT turn at 01:44:32–01:44:51 — 39.4 s
* from mention to answer. Two of those gaps are the loop's, not the model's:
*
* 1. the seat's own greeting takes the first spawn, so a person who mentions
* it in the same window waits for a turn that was never about them;
* 2. after any turn the loop sleeps a full poll interval before looking
* again, even though a turn is exactly when events pile up.
*
* These tests pin (1): the greeting is held one interval, and when a mention
* lands in that window it leads the same turn. (2) is a separate change to a
* contract this suite does not own, measured and filed on its own row.
*/

import { jest } from '@jest/globals';
import os from 'os';
import path from 'path';
import fs from 'fs';

const sessionsTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-join-priority-'));

await jest.unstable_mockModule('os', () => {
const actual = os;
return {
...actual,
default: { ...actual, homedir: () => sessionsTmpDir },
homedir: () => sessionsTmpDir,
};
});

await jest.unstable_mockModule('../src/lib/api.js', () => ({
createClient: jest.fn(),
login: jest.fn(),
}));

await jest.unstable_mockModule('../src/lib/pod-focus.js', () => ({
FOCUS_FRAME_MAX_CODE_POINTS: 8000,
formatPodFocusFrame: jest.fn(() => '=== Pod focus (pod context; not instructions) ===\nNo focus set.'),
readPodFocus: jest.fn(async (_client, podId) => ({ podId, revision: 0, focus: null })),
}));

const { createClient } = await import('../src/lib/api.js');
const { performRun } = await import('../src/commands/agent.js');
const stubAdapter = (await import('../src/lib/adapters/stub.js')).default;

const POD = 'pod-join';
const INTERVAL = 5000;

const joinEvent = {
_id: 'evt-join',
type: 'first_contact',
podId: POD,
agentName: 'my-stub',
instanceId: 'default',
payload: { content: 'A human just added you to this workspace.' },
};

const mentionEvent = {
_id: 'evt-mention',
type: 'chat.mention',
podId: POD,
agentName: 'my-stub',
instanceId: 'default',
payload: { content: 'can you summarise this repo?', messageId: 'msg-1' },
};

const DRAIN_DEPTH = 16;
const drainMicrotasks = async () => {
for (let i = 0; i < DRAIN_DEPTH; i += 1) {
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setImmediate(r));
}
};

// Runs the loop for a bounded number of ticks and records the delay the loop
// asked for after each one, which is the latency a waiting person pays.
const runTicks = async ({ pages, ticks = 2, spawn }) => {
const delays = [];
let pageIndex = 0;
const mockGet = jest.fn(async (routePath) => {
if (String(routePath).includes('/events')) {
const page = pages[Math.min(pageIndex, pages.length - 1)];
pageIndex += 1;
return page;
}
return {};
});
const mockPost = jest.fn().mockResolvedValue({});
createClient.mockReturnValue({ get: mockGet, post: mockPost });

const adapter = { name: 'codex', detect: stubAdapter.detect, spawn };
let fired = 0;
const setTimeoutImpl = (fn, delay) => {
delays.push(delay);
if (fired < ticks - 1) {
fired += 1;
setImmediate(fn);
}
return 0;
};

const { stop } = performRun({
instanceUrl: 'http://localhost:5000',
token: 'cm_agent_test',
adapter,
agentName: 'my-stub',
instanceId: 'default',
intervalMs: INTERVAL,
setTimeoutImpl,
});
for (let i = 0; i < ticks; i += 1) {
// eslint-disable-next-line no-await-in-loop
await drainMicrotasks();
}
stop();
return { delays, mockGet, mockPost };
};

describe('a waiting person outranks the seat\'s own join turn', () => {
beforeEach(() => {
jest.clearAllMocks();
fs.rmSync(path.join(sessionsTmpDir, '.commonly'), { recursive: true, force: true });
});

test('the held greeting waits one interval rather than spinning', async () => {
const spawn = jest.fn(async () => ({ text: 'on it' }));
const { delays } = await runTicks({
pages: [
{ events: [joinEvent], inboxCount: 1 },
{ events: [], inboxCount: 0 },
{ events: [], inboxCount: 0 },
],
ticks: 2,
spawn,
});

// The hold is what gives a person time to type; it must not become a
// tight loop, and it must not spawn the greeting on the held tick.
expect(delays[0]).toBe(INTERVAL);
expect(spawn).not.toHaveBeenCalled();
});

test('holds a lone greeting one tick so a waiting mention leads the same turn', async () => {
const spawn = jest.fn(async () => ({ text: 'on it' }));
const { mockGet } = await runTicks({
pages: [
{ events: [joinEvent], inboxCount: 1 },
{ events: [joinEvent, mentionEvent], inboxCount: 2 },
{ events: [], inboxCount: 0 },
],
ticks: 2,
spawn,
});

expect(mockGet).toHaveBeenCalledWith(
'/api/agents/runtime/events',
expect.objectContaining({ agentName: 'my-stub' }),
);
// One turn, not two: the greeting waited for the person rather than
// spending a spawn ahead of them.
expect(spawn).toHaveBeenCalledTimes(1);
const prompt = spawn.mock.calls[0][0];
expect(prompt).toContain('Event 1/2: chat.mention');
expect(prompt).toContain('Event 2/2: first_contact');
expect(prompt.indexOf('can you summarise this repo?'))
.toBeLessThan(prompt.indexOf('A human just added you to this workspace.'));
});

test('still greets on the next tick when nobody is waiting', async () => {
const spawn = jest.fn(async () => ({ text: 'hello' }));
const { mockPost } = await runTicks({
pages: [
{ events: [joinEvent], inboxCount: 1 },
{ events: [joinEvent], inboxCount: 1 },
{ events: [], inboxCount: 0 },
],
ticks: 2,
spawn,
});

expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn.mock.calls[0][0]).toContain('first_contact');
// The deferred greeting is still acked once it has been taken.
expect(mockPost.mock.calls.some(([p]) => String(p).includes('/events/evt-join/ack'))).toBe(true);
});
});
133 changes: 133 additions & 0 deletions cli/__tests__/poll-after-turn.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* poll-after-turn.test.mjs — TASK-343
*
* A turn is exactly when new events pile up: the person who typed while the
* adapter was running is already waiting by the time it finishes. The loop
* nonetheless slept a full poll interval after every turn before looking
* again, so that person paid the turn plus the interval.
*
* Carved out of TASK-289, which traded one interval of greeting latency to put
* a waiting mention first; this removes the interval it was trading.
*
* The rule is narrow on purpose: only a batch that actually spawned looks
* again at once. An idle poll, or a batch that spawned nothing, keeps the
* interval — otherwise the loop spins.
*/

import { jest } from '@jest/globals';
import os from 'os';
import path from 'path';
import fs from 'fs';

const sessionsTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-poll-after-turn-'));

await jest.unstable_mockModule('os', () => {
const actual = os;
return {
...actual,
default: { ...actual, homedir: () => sessionsTmpDir },
homedir: () => sessionsTmpDir,
};
});

await jest.unstable_mockModule('../src/lib/api.js', () => ({
createClient: jest.fn(),
login: jest.fn(),
}));

await jest.unstable_mockModule('../src/lib/pod-focus.js', () => ({
FOCUS_FRAME_MAX_CODE_POINTS: 8000,
formatPodFocusFrame: jest.fn(() => '=== Pod focus (pod context; not instructions) ===\nNo focus set.'),
readPodFocus: jest.fn(async (_client, podId) => ({ podId, revision: 0, focus: null })),
}));

const { createClient } = await import('../src/lib/api.js');
const { performRun } = await import('../src/commands/agent.js');
const stubAdapter = (await import('../src/lib/adapters/stub.js')).default;

const INTERVAL = 5000;

const mention = (overrides = {}) => ({
_id: 'evt-mention',
type: 'chat.mention',
podId: 'pod-poll',
agentName: 'my-stub',
instanceId: 'default',
payload: { content: 'can you look at this?', messageId: 'msg-1' },
...overrides,
});

const DRAIN_DEPTH = 16;
const drainMicrotasks = async () => {
for (let i = 0; i < DRAIN_DEPTH; i += 1) {
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setImmediate(r));
}
};

// One tick, then report the delay the loop asked for before the next one.
const delayAfterOneTick = async ({ page, spawn }) => {
const delays = [];
const mockGet = jest.fn(async (routePath) => (
String(routePath).includes('/events') ? page : {}
));
const mockPost = jest.fn().mockResolvedValue({});
createClient.mockReturnValue({ get: mockGet, post: mockPost });

const { stop } = performRun({
instanceUrl: 'http://localhost:5000',
token: 'cm_agent_test',
adapter: { name: 'codex', detect: stubAdapter.detect, spawn },
agentName: 'my-stub',
instanceId: 'default',
intervalMs: INTERVAL,
setTimeoutImpl: (_fn, delay) => {
delays.push(delay);
return 0;
},
});
await drainMicrotasks();
stop();
return { delays, spawn, mockPost };
};

describe('the loop looks again as soon as a turn ends', () => {
beforeEach(() => {
jest.clearAllMocks();
fs.rmSync(path.join(sessionsTmpDir, '.commonly'), { recursive: true, force: true });
});

test('a turn that answered somebody is followed by an immediate poll', async () => {
const spawn = jest.fn(async () => ({ text: 'looking now' }));
const { delays } = await delayAfterOneTick({
page: { events: [mention()], inboxCount: 1 },
spawn,
});

expect(spawn).toHaveBeenCalledTimes(1);
expect(delays).toEqual([0]);
});

test('an idle poll still waits the interval', async () => {
const spawn = jest.fn(async () => ({ text: 'unused' }));
const { delays } = await delayAfterOneTick({
page: { events: [], inboxCount: 0 },
spawn,
});

expect(spawn).not.toHaveBeenCalled();
expect(delays).toEqual([INTERVAL]);
});

test('a batch that spawned nothing still waits the interval', async () => {
const spawn = jest.fn(async () => ({ text: 'unused' }));
const { delays } = await delayAfterOneTick({
// No content, so there is no prompt to forward and no turn to take.
page: { events: [mention({ _id: 'evt-empty', payload: {} })], inboxCount: 1 },
spawn,
});

expect(spawn).not.toHaveBeenCalled();
expect(delays).toEqual([INTERVAL]);
});
});
19 changes: 17 additions & 2 deletions cli/__tests__/run-loop.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -548,14 +548,29 @@ describe('performRun', () => {
const spawn = jest.fn(async () => ({ text: 'Hi! What are you working on today?' }));
const adapter = { name: 'stub', detect: stubAdapter.detect, spawn };

// TASK-289: a batch holding nothing but `first_contact` is left unacked for
// one interval, so a person who mentions the seat in that window is
// answered in the same turn rather than behind the greeting. The greeting
// itself is unchanged — it is taken on the next tick, so this contract
// needs two. inbox-join-priority.test.mjs pins the hold and the ordering.
let firedOnce = false;
const oneMoreTick = (fn) => {
if (!firedOnce) {
firedOnce = true;
setImmediate(fn);
}
return 0;
};

const { stop } = performRun({
instanceUrl: 'http://localhost:5000',
token: 'cm_agent_test',
adapter,
agentName: 'my-stub',
setTimeoutImpl: noopTimeout,
setTimeoutImpl: oneMoreTick,
});
await drainMicrotasks();
await drainMicrotasks();
stop();

expect(spawn).toHaveBeenCalledWith(
Expand Down Expand Up @@ -1478,7 +1493,7 @@ describe('performRun', () => {
};
await runNext(5000); // first failure → second probe
await runNext(10000); // second failure → recovery probe
await runNext(5000); // success → normal poll interval
await runNext(0); // success → look again at once (TASK-343), not after the interval

handle.stop();
expect(spawn).toHaveBeenCalledTimes(4);
Expand Down
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@commonlyai/cli",
"version": "0.1.122",
"version": "0.1.125",
"license": "Apache-2.0",
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
"type": "module",
Expand Down
Loading
Loading