Skip to content
Merged
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
10 changes: 6 additions & 4 deletions backend/__tests__/unit/models/PgMessage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ describe('PG Message model', () => {
expect(pool.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining('INSERT INTO messages'),
// Seventh param is the RESOLVED thread root (@ux-lead 56879): null here
// Eighth param is the RESOLVED thread root (@ux-lead 56879): null here
// because this call names none, which makes the SQL fall through to
// deriving from the reply edge — the pre-existing behaviour.
['p', 'u', 'c', 'text', null, null, null],
['p', 'u', 'c', 'text', null, null, null, null],
);
expect(pool.query).toHaveBeenNthCalledWith(
2,
Expand Down Expand Up @@ -83,7 +83,7 @@ describe('PG Message model', () => {

// The message the posting run is answering (agentAnswerService) reaches the
// attention writer with the row and is never stored: the INSERT carries the
// same seven parameters, and no reply edge.
// same eight parameters, and no reply edge.
it("hands the run's answeringMessageId to the attention writer without storing it", async () => {
pool.query
.mockResolvedValueOnce({
Expand All @@ -98,7 +98,7 @@ describe('PG Message model', () => {
expect(pool.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining('INSERT INTO messages'),
['pod-1', 'agent', '@lee done', 'text', null, null, null],
['pod-1', 'agent', '@lee done', 'text', null, null, null, null],
);
expect(mockRecordMentionedUsers).toHaveBeenCalledWith(expect.objectContaining({
id: '3', answeringMessageId: '78385',
Expand All @@ -122,6 +122,7 @@ describe('PG Message model', () => {
});
const res = await Message.findByPodId('p');
expect(pool.query).toHaveBeenCalled();
expect(pool.query.mock.calls[0][0]).toContain('m.author_kind');
expect(res[0]).toHaveProperty('_id', '1');
expect(res[0]).toHaveProperty('userId');
expect(res[0]).toHaveProperty('messageType', 'text');
Expand Down Expand Up @@ -156,6 +157,7 @@ describe('PG Message model', () => {
});
const msg = await Message.findById('1');
expect(pool.query).toHaveBeenCalledWith(expect.any(String), ['1']);
expect(pool.query.mock.calls[0][0]).toContain('m.author_kind');
expect(msg).toHaveProperty('id', '1');
expect(msg).toHaveProperty('messageType');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ beforeAll(async () => {
user_id VARCHAR(255) NOT NULL,
content TEXT,
message_type VARCHAR(50) DEFAULT 'text',
author_kind VARCHAR(20),
payload JSONB,
reply_to_message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
thread_root_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
Expand Down
2 changes: 1 addition & 1 deletion backend/__tests__/unit/models/threadRootDerivation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe('derivation on write', () => {
it('derives from the reply edge and nothing else', () => {
const insert = SRC.slice(SRC.indexOf('INSERT INTO messages'), SRC.indexOf('RETURNING *'));
expect(insert).toContain('thread_root_id');
expect(insert).toMatch(/WHERE parent\.id = \$5/); // $5 is replyToMessageId
expect(insert).toMatch(/WHERE parent\.id = \$6/); // $6 is replyToMessageId
});

it('a message with no reply edge gets a NULL root, not its own id', () => {
Expand Down
5 changes: 5 additions & 0 deletions backend/__tests__/unit/models/threadingSchemaRetrofit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ describe('every late column on messages is retrofitted, not just declared', () =
test('thread_root_id has an ALTER — the CREATE TABLE alone cannot reach an existing instance', () => {
const all = [...alters, ...multiLineAlters].join('\n');
expect(all).toMatch(/thread_root_id/);
expect(all).toMatch(/author_kind/);
});

test('applied to a PRE-EXISTING messages table, the column appears', async () => {
Expand All @@ -69,13 +70,15 @@ describe('every late column on messages is retrofitted, not just declared', () =
const pool = new (db.adapters.createPg().Pool)();
await pool.query(OLD_MESSAGES);
expect(await columnsOf(pool, 'messages')).not.toContain('thread_root_id');
expect(await columnsOf(pool, 'messages')).not.toContain('author_kind');

for (const stmt of [...alters, ...multiLineAlters]) {
// eslint-disable-next-line no-await-in-loop
await pool.query(stmt);
}

expect(await columnsOf(pool, 'messages')).toContain('thread_root_id');
expect(await columnsOf(pool, 'messages')).toContain('author_kind');
});

test('CONTROL: the old shape genuinely lacks it, so the test above measures something', async () => {
Expand All @@ -85,6 +88,7 @@ describe('every late column on messages is retrofitted, not just declared', () =
const cols = await columnsOf(pool, 'messages');
expect(cols).toContain('reply_to_message_id');
expect(cols).not.toContain('thread_root_id');
expect(cols).not.toContain('author_kind');
});

test('the retrofits are idempotent — boot DDL runs on every start', async () => {
Expand All @@ -98,6 +102,7 @@ describe('every late column on messages is retrofitted, not just declared', () =
}
}
expect(await columnsOf(pool, 'messages')).toContain('thread_root_id');
expect(await columnsOf(pool, 'messages')).toContain('author_kind');
});

test('the index on thread_root_id is created AFTER the retrofit that adds it', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ describe('registry install — a hosted agent joins with one post', () => {
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true }));
expect(introPosts()).toHaveLength(1);
expect(introPosts()[0][0].content).toMatch(/@my-agent/);
expect(introPosts()[0][0].authorKind).toBe('kernel');
});

it('on the Durable Object, the intro posts and no handle is passed to the greeting', async () => {
Expand All @@ -175,5 +176,6 @@ describe('registry install — a hosted agent joins with one post', () => {

expect(mockFireFirstContact).toHaveBeenCalledWith(expect.not.objectContaining({ mentionHandle: expect.anything() }));
expect(introPosts()).toHaveLength(1);
expect(introPosts()[0][0].authorKind).toBe('kernel');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// @ts-nocheck

import { backfillMessageAuthorKind, MessageAuthorKindBackfillDeps } from '../../../scripts/backfill-message-author-kind';

Check warning on line 3 in backend/__tests__/unit/scripts/backfill-message-author-kind.test.ts

View workflow job for this annotation

GitHub Actions / Backend typecheck and lint

This line has a length of 121. Maximum allowed is 120

const makeDeps = (overrides: Partial<MessageAuthorKindBackfillDeps> = {}): MessageAuthorKindBackfillDeps => ({
getEpisodeReferences: async () => ['101', '507f1f77bcf86cd799439011', 'not-a-message'],
getPostgresRows: async () => [{ id: 101, author_kind: null }],
getMongoRows: async () => [{ _id: '507f1f77bcf86cd799439011', authorKind: null }],
updatePostgresRows: async (ids) => ids.length,
updateMongoRows: async (ids) => ids.length,
...overrides,
});

describe('backfill-message-author-kind', () => {
it('reports both stores and writes nothing in the default dry run', async () => {
const updatePostgresRows = jest.fn(async () => 1);
const updateMongoRows = jest.fn(async () => 1);

const report = await backfillMessageAuthorKind(makeDeps({ updatePostgresRows, updateMongoRows }));

expect(report).toMatchObject({
dryRun: true,
episodeReferences: 3,
uniqueMessageReferences: 3,
postgresRows: 1,
mongoRows: 1,
invalidReferences: 1,
missingReferences: 1,
alreadyKernel: 0,
toBackfill: 2,
appliedPostgres: 0,
appliedMongo: 0,
});
expect(updatePostgresRows).not.toHaveBeenCalled();
expect(updateMongoRows).not.toHaveBeenCalled();
});

it('applies only unmarked rows and is idempotent by source-row state', async () => {
const postgresRows = [
{ id: 101, author_kind: null },
{ id: 102, author_kind: 'kernel' },
];
const mongoRows = [
{ _id: '507f1f77bcf86cd799439011', authorKind: null },
{ _id: '507f1f77bcf86cd799439012', authorKind: 'kernel' },
];
const updatePostgresRows = jest.fn(async (ids: string[]) => ids.length);
const updateMongoRows = jest.fn(async (ids: string[]) => ids.length);
const deps = makeDeps({
getEpisodeReferences: async () => ['101', '102', '507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012'],
getPostgresRows: async () => postgresRows,
getMongoRows: async () => mongoRows,
updatePostgresRows,
updateMongoRows,
});

const first = await backfillMessageAuthorKind(deps, { apply: true });
expect(first).toMatchObject({ dryRun: false, alreadyKernel: 2, toBackfill: 2, appliedPostgres: 1, appliedMongo: 1 });

Check warning on line 58 in backend/__tests__/unit/scripts/backfill-message-author-kind.test.ts

View workflow job for this annotation

GitHub Actions / Backend typecheck and lint

This line has a length of 121. Maximum allowed is 120
expect(updatePostgresRows).toHaveBeenCalledWith(['101']);
expect(updateMongoRows).toHaveBeenCalledWith(['507f1f77bcf86cd799439011']);

postgresRows[0].author_kind = 'kernel';
mongoRows[0].authorKind = 'kernel';
const second = await backfillMessageAuthorKind(deps, { apply: true });
expect(second).toMatchObject({ alreadyKernel: 4, toBackfill: 0, appliedPostgres: 0, appliedMongo: 0 });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,53 @@ describe('agent in-thread posts (threadRootId, no reply edge)', () => {
);
});

it('marks trusted stalled-connect nudges as kernel-authored and leaves agent posts unmarked', async () => {
PGMessage.create.mockResolvedValueOnce({
id: '57604',
content: 'Your agent is waiting for a connection.',
message_type: 'text',
author_kind: 'kernel',
created_at: new Date('2026-08-23T00:00:00Z'),
thread_root_id: null,
});

await AgentMessageService.postMessage({
agentName: 'sprint-review',
instanceId: 'default',
podId: POD,
content: 'Your agent is waiting for a connection.',
metadata: { kind: 'stalled-connect-nudge' },
authorKind: 'kernel',
});

expect(PGMessage.create).toHaveBeenCalledWith(
POD, 'agent-user-1', 'Your agent is waiting for a connection.', 'text', null, null, null, undefined, 'kernel',
);
const kernelMessage = emitted.find((entry) => entry.payload.id === '57604');
expect(kernelMessage.payload.author_kind).toBe('kernel');

PGMessage.create.mockResolvedValueOnce({
id: '57605',
content: 'I am connected now.',
message_type: 'text',
author_kind: null,
created_at: new Date('2026-08-23T00:00:00Z'),
thread_root_id: null,
});
await AgentMessageService.postMessage({
agentName: 'sprint-review',
instanceId: 'default',
podId: POD,
content: 'I am connected now.',
});

expect(PGMessage.create).toHaveBeenLastCalledWith(
POD, 'agent-user-1', 'I am connected now.', 'text', null, null, null, undefined,
);
const agentMessage = emitted.find((entry) => entry.payload.id === '57605');
expect(agentMessage.payload.author_kind).toBeNull();
});

it('forwards a relay-only decision card to the outbound dispatcher without persisting it', async () => {
const card = {
title: 'Choose a train',
Expand Down Expand Up @@ -250,6 +297,34 @@ describe('agent in-thread posts (threadRootId, no reply edge)', () => {
expect(relayPayloads()).toEqual([expect.objectContaining({ podId: POD, noOutboundRelay: true })]);
});

it('serializes a Mongoose document before overlaying the kernel author kind', async () => {
delete process.env.PG_HOST;
const Message = require('../../../models/Message');
Message.mockImplementation((doc) => {
const persisted = { ...doc, _id: 'mongo-message-kernel' };
return {
$__: {},
_doc: persisted,
save: jest.fn().mockResolvedValue(undefined),
populate: jest.fn().mockResolvedValue(undefined),
toObject: jest.fn(() => persisted),
};
});

await AgentMessageService.postMessage({
agentName: 'scout', instanceId: 'default', podId: POD,
content: 'Your agent is waiting for a connection.',
authorKind: 'kernel',
});

const broadcast = emitted.find((entry) => entry.event === 'newMessage');
expect(broadcast.payload).toMatchObject({
_id: 'mongo-message-kernel',
content: 'Your agent is waiting for a connection.',
author_kind: 'kernel',
});
});

it('on the error-to-DM route, for the DM post and the source notice', async () => {
PGMessage.create.mockResolvedValue({
id: '57604', content: 'x', message_type: 'text', created_at: new Date(), thread_root_id: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ describe('stalledConnectService.scan', () => {
expect(mockPost).toHaveBeenCalledTimes(1);
const posted = mockPost.mock.calls[0][0];
expect(posted.podId).toBe('pod1');
expect(posted.authorKind).toBe('kernel');
expect(posted.content).toContain('@alice');
expect(posted.content).toContain('commonly agent run ai1-agent');
expect(posted.content).toContain("I'll post here the moment I connect");
Expand Down
9 changes: 9 additions & 0 deletions backend/config/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ CREATE TABLE IF NOT EXISTS messages (
user_id VARCHAR(24) NOT NULL,
content TEXT NOT NULL,
message_type VARCHAR(20) DEFAULT 'text' NOT NULL,
-- Kernel-authored seat posts (install intros and stalled-connect nudges)
-- speak beside an agent without pretending to be that agent. NULL keeps
-- legacy human and agent rows unclassified until a trusted server path opts
-- into the marker.
author_kind VARCHAR(20),
reply_to_message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
-- Threading (W-T, TASK-029). DELIBERATELY separate from reply_to_message_id,
-- which is an ADDRESSING edge: it feeds `isRouted` and the implicit-reply
Expand Down Expand Up @@ -77,6 +82,10 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS is_bot BOOLEAN DEFAULT false;
-- as is_bot above. NULL for ordinary messages.
ALTER TABLE messages ADD COLUMN IF NOT EXISTS payload JSONB;

-- FUNNEL-5: the CREATE TABLE declaration above reaches fresh databases; this
-- retrofit reaches every instance whose messages table already exists.
ALTER TABLE messages ADD COLUMN IF NOT EXISTS author_kind VARCHAR(20);

-- Threading (W-T, TASK-029). MUST be retrofitted, not only declared in the
-- CREATE TABLE above — `CREATE TABLE IF NOT EXISTS` is a no-op on an existing
-- table, so a column declared only there NEVER reaches an instance that
Expand Down
2 changes: 2 additions & 0 deletions backend/controllers/messageController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface NormalizedMessage {
username?: string;
content: string;
message_type: string;
author_kind?: 'kernel' | null;
// ADR-020 D3: structured card payload — must survive the Mongo fallback's
// whitelist or cards silently vanish on that path.
payload?: unknown;
Expand Down Expand Up @@ -67,6 +68,7 @@ const normalizeMongo = (m: Record<string, unknown>): NormalizedMessage => {
user_id: idSource ? (idSource as { toString(): string }).toString() : '',
content: m.content as string,
message_type: (m.messageType as string) || 'text',
author_kind: m.authorKind === 'kernel' || m.author_kind === 'kernel' ? 'kernel' : null,
...(m.payload != null ? { payload: m.payload } : {}),
created_at: m.createdAt,
updated_at: m.updatedAt,
Expand Down
6 changes: 6 additions & 0 deletions backend/models/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import mongoose, { Document, Model, Schema, Types } from 'mongoose';
// regex-sniffed out of `content`. The union and the schema enum below are
// declared independently — change BOTH.
export type MessageType = 'text' | 'image' | 'system' | 'card';
export type MessageAuthorKind = 'kernel' | null;

export interface IMessage extends Document {
podId: Types.ObjectId;
userId: Types.ObjectId;
content: string;
messageType: MessageType;
/** Trusted Commonly-authored prose posted beside an agent identity. */
authorKind?: MessageAuthorKind;
// Structured component payload (approval cards). Mixed like
// AgentEvent.payload; null/absent for ordinary messages.
payload?: unknown;
Expand All @@ -31,6 +34,9 @@ const MessageSchema = new Schema<IMessage>({
enum: ['text', 'image', 'system', 'card'],
default: 'text',
},
// Trusted server-authored prose that shares an agent seat's identity.
// Ordinary human and agent posts remain null.
authorKind: { type: String, enum: ['kernel', null], default: null },
payload: { type: Schema.Types.Mixed },
metadata: { type: Schema.Types.Mixed },
createdAt: { type: Date, default: Date.now },
Expand Down
Loading
Loading