Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
07d8976
feat(v2): rebuild workspace pods sidebar
lilyshen0722 Sep 5, 2026
33b16b1
fix(v2): preserve sidebar row geometry
lilyshen0722 Sep 5, 2026
50d953f
test(v2): cover sidebar pod type mapping
lilyshen0722 Sep 5, 2026
a51b421
Merge branch 'main' into feat/v2-workspace-sidebar-artboard
lilyshen0722 Sep 5, 2026
82fa5ef
fix(v2): size workspace rail to artboard
lilyshen0722 Sep 5, 2026
6ae0ad1
feat(v2): rebuild workspace inspector
lilyshen0722 Sep 5, 2026
b6adfa3
fix(v2): finish inspector artboard states
lilyshen0722 Sep 5, 2026
fd6512d
feat(v2): rebuild workspace thread surface
lilyshen0722 Sep 5, 2026
12cb86f
test(v2): pin decision card cobalt exception
lilyshen0722 Sep 5, 2026
fe75f75
refactor(v2): isolate thread mention state
lilyshen0722 Sep 5, 2026
6032a0c
feat(v2): complete workspace phone thread
lilyshen0722 Sep 5, 2026
7fe9330
fix(v2): make first decision option primary
lilyshen0722 Sep 5, 2026
38272e0
test(v2): cover empty inspector attention
lilyshen0722 Sep 5, 2026
70e9d9b
fix(v2): align workspace thread to artboard
lilyshen0722 Sep 5, 2026
6cceac6
fix(v2): activate artboard message row rules
lilyshen0722 Sep 5, 2026
e76651c
fix(v2): keep workspace columns fixed through tablet
lilyshen0722 Sep 5, 2026
7ef104e
fix(v2): match workspace row type scale
lilyshen0722 Sep 5, 2026
b7dd542
fix(v2): finish workspace artboard states
lilyshen0722 Sep 5, 2026
1d9b274
docs(v2): explain phone inspector first frame
lilyshen0722 Sep 5, 2026
7490744
feat(decision): carry card through relay dispatch
lilyshen0722 Sep 5, 2026
b72b33a
feat(connectors): add channel verdict ledger
lilyshen0722 Sep 5, 2026
08a5686
feat(connectors): retain channel verdicts through settlement
lilyshen0722 Sep 5, 2026
155ae12
feat(connectors): relay decision cards to channels
lilyshen0722 Sep 6, 2026
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
57 changes: 57 additions & 0 deletions backend/__tests__/unit/models/ChannelVerdict.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// This model suite uses the in-memory Mongo helper; keep its JWT dependency
// loadable on Node 26, as the other database-backed unit suites do.
jest.mock('jsonwebtoken', () => ({}));

const mongoose = require('mongoose');
const ChannelVerdict = require('../../../models/ChannelVerdict');
const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../../utils/testUtils');

describe('ChannelVerdict', () => {
beforeAll(async () => {
await setupMongoDb();
await ChannelVerdict.syncIndexes();
});
afterAll(() => closeMongoDb());
afterEach(() => clearMongoDb());

const row = (overrides = {}) => ({
integrationId: new mongoose.Types.ObjectId(),
podId: new mongoose.Types.ObjectId(),
provider: 'telegram',
event: { kind: 'decision_request', podMessageId: '700' },
verdict: 'interrupt',
reason: 'card',
at: new Date(),
...overrides,
});

it('keeps one connector receipt per workspace message and preserves the D7 read indexes', async () => {
const first = row();
await ChannelVerdict.create(first);
await expect(ChannelVerdict.create({ ...first, reason: 'retry' }))
.rejects.toMatchObject({ code: 11000 });

const indexes = ChannelVerdict.schema.indexes();
expect(indexes).toEqual(expect.arrayContaining([
[{ integrationId: 1, at: -1 }, expect.any(Object)],
[{ podId: 1, at: -1 }, expect.any(Object)],
[{ 'event.podMessageId': 1 }, expect.any(Object)],
[{ expiresAt: 1 }, expect.objectContaining({ expireAfterSeconds: 0 })],
]));
});

it('accepts a lazily-linked decision id and a later channel receipt', async () => {
const verdict = new ChannelVerdict(row({
provider: 'slack',
event: {
kind: 'decision_request',
podMessageId: '701',
decisionId: new mongoose.Types.ObjectId(),
},
reachedHumanAt: new Date(),
ruledVia: 'slack',
expiresAt: new Date(Date.now() + (90 * 24 * 60 * 60 * 1000)),
}));
await expect(verdict.validate()).resolves.toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const DMService = require('../../../services/dmService');
const PGMessage = require('../../../models/pg/Message');
const File = require('../../../models/File');
const threadRootResolver = require('../../../services/threadRootResolver');
const mockDispatch = jest.fn();

jest.mock('../../../models/Message');
jest.mock('../../../models/Summary', () => ({
Expand Down Expand Up @@ -88,6 +89,9 @@ jest.mock('../../../services/threadRootResolver', () => {
}
return { resolveThreadRoot: jest.fn(), ThreadRootError };
});
jest.mock('../../../services/installable/eventHandlers', () => ({
dispatch: (...args) => mockDispatch(...args),
}));

const POD = '6a0da39bae757028b39f87a6';
let emitted;
Expand Down Expand Up @@ -165,6 +169,39 @@ describe('agent in-thread posts (threadRootId, no reply edge)', () => {
);
});

it('forwards a relay-only decision card to the outbound dispatcher without persisting it', async () => {
const card = {
title: 'Choose a train',
question: 'Which rollout should ship?',
options: [{ label: 'Canary' }, { label: 'Fast lane' }],
};
const content = 'Choose a train\n\nWhich rollout should ship?';
PGMessage.create.mockResolvedValueOnce({
id: '57602',
content,
message_type: 'text',
created_at: new Date('2026-08-23T00:00:00Z'),
thread_root_id: null,
});

await AgentMessageService.postMessage({
agentName: 'sprint-review',
instanceId: 'default',
podId: POD,
content,
relayCard: card,
});

expect(PGMessage.create).toHaveBeenCalledWith(
POD, 'agent-user-1', content, 'text', null, null, null,
);
expect(mockDispatch).toHaveBeenCalledWith('chat.message', expect.objectContaining({
podId: POD,
content,
card,
}));
});

it('a ThreadRootError propagates — never swallowed into the Mongo fallback', async () => {
threadRootResolver.resolveThreadRoot.mockRejectedValue(
new threadRootResolver.ThreadRootError('root is in another pod', 'thread_root_other_pod'),
Expand Down
108 changes: 108 additions & 0 deletions backend/__tests__/unit/services/channelVerdictService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
const mockChannelVerdict = { updateOne: jest.fn(), updateMany: jest.fn() };
jest.mock('../../../models/ChannelVerdict', () => mockChannelVerdict);

const {
record, markReachedHuman, markRuled, CHANNEL_VERDICT_RETENTION_MS,
} = require('../../../services/channelVerdictService');

describe('channelVerdictService', () => {
beforeEach(() => {
jest.clearAllMocks();
mockChannelVerdict.updateOne.mockResolvedValue({ acknowledged: true });
mockChannelVerdict.updateMany.mockResolvedValue({ matchedCount: 1 });
});

it('upserts an interrupt receipt on its channel and workspace message', async () => {
const at = new Date('2026-09-05T13:00:00.000Z');
await record({
integrationId: 'integration-a',
installationId: 'installation-a',
podId: 'pod-a',
provider: 'telegram',
event: { kind: 'decision_request', podMessageId: '700' },
verdict: 'interrupt',
reason: 'card',
at,
});

expect(mockChannelVerdict.updateOne).toHaveBeenCalledWith(
{ integrationId: 'integration-a', 'event.podMessageId': '700' },
{
$setOnInsert: expect.objectContaining({
integrationId: 'integration-a', installationId: 'installation-a', podId: 'pod-a',
provider: 'telegram', event: { kind: 'decision_request', podMessageId: '700' },
verdict: 'interrupt', reason: 'card', at,
}),
},
{ upsert: true },
);
expect(mockChannelVerdict.updateOne.mock.calls[0][1].$setOnInsert).not.toHaveProperty('expiresAt');
});

it('expires completed verdicts a quarter after their own event time', async () => {
const at = new Date('2026-09-05T13:00:00.000Z');
await record({
integrationId: 'integration-a', podId: 'pod-a', provider: 'telegram',
event: { kind: 'chat.message', podMessageId: '701' },
verdict: 'hold', reason: 'muted', at,
});

expect(mockChannelVerdict.updateOne.mock.calls[0][1].$setOnInsert.expiresAt)
.toEqual(new Date(at.getTime() + CHANNEL_VERDICT_RETENTION_MS));
});

it('stamps only the channel that supplied a ruling', async () => {
mockChannelVerdict.updateOne.mockResolvedValueOnce({ matchedCount: 1 });
await markReachedHuman({
integrationId: 'integration-b', podMessageId: '700', ruledVia: 'slack',
});

expect(mockChannelVerdict.updateOne).toHaveBeenCalledWith(
{ integrationId: 'integration-b', 'event.podMessageId': '700' },
{ $set: expect.objectContaining({ ruledVia: 'slack', reachedHumanAt: expect.any(Date) }) },
);
expect(mockChannelVerdict.updateMany).toHaveBeenCalledWith(
{ 'event.podMessageId': '700' },
{ $set: expect.objectContaining({ ruledVia: 'slack', expiresAt: expect.any(Date) }) },
);
});

it('marks every copy ruled without inventing a channel receipt', async () => {
await markRuled({ podMessageId: '700', ruledVia: 'workspace' });

expect(mockChannelVerdict.updateMany).toHaveBeenCalledWith(
{ 'event.podMessageId': '700' },
{ $set: expect.objectContaining({ ruledVia: 'workspace', expiresAt: expect.any(Date) }) },
);
expect(mockChannelVerdict.updateOne).not.toHaveBeenCalled();
});

it('logs a missing channel instead of stamping its sibling copies', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
mockChannelVerdict.updateOne.mockResolvedValueOnce({ matchedCount: 0 });

await markReachedHuman({
integrationId: 'expired-integration', podMessageId: 'expired-card', ruledVia: 'telegram',
});

expect(mockChannelVerdict.updateMany).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
'[channel-verdict] reach stamp matched no channel:', 'expired-card',
);
warn.mockRestore();
});

it('keeps a failed observational write out of the bridge outcome', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
mockChannelVerdict.updateOne.mockRejectedValueOnce(new Error('database unavailable'));

await expect(record({
integrationId: 'integration-a', podId: 'pod-a', provider: 'telegram',
event: { kind: 'decision_request', podMessageId: '700' },
verdict: 'interrupt', reason: 'card',
})).resolves.toBeUndefined();

expect(warn).toHaveBeenCalledWith('[channel-verdict] record failed:', 'database unavailable');
warn.mockRestore();
});
});
Loading