diff --git a/backend/__tests__/unit/models/PgMessage.test.js b/backend/__tests__/unit/models/PgMessage.test.js index 2db78953e3..11f3bf2542 100644 --- a/backend/__tests__/unit/models/PgMessage.test.js +++ b/backend/__tests__/unit/models/PgMessage.test.js @@ -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, @@ -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({ @@ -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', @@ -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'); @@ -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'); }); diff --git a/backend/__tests__/unit/models/threadRootDerivation.pgmem.test.js b/backend/__tests__/unit/models/threadRootDerivation.pgmem.test.js index 6475aec1e9..5ef8b3eaa3 100644 --- a/backend/__tests__/unit/models/threadRootDerivation.pgmem.test.js +++ b/backend/__tests__/unit/models/threadRootDerivation.pgmem.test.js @@ -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, diff --git a/backend/__tests__/unit/models/threadRootDerivation.test.js b/backend/__tests__/unit/models/threadRootDerivation.test.js index aca0783a24..727a061f87 100644 --- a/backend/__tests__/unit/models/threadRootDerivation.test.js +++ b/backend/__tests__/unit/models/threadRootDerivation.test.js @@ -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', () => { diff --git a/backend/__tests__/unit/models/threadingSchemaRetrofit.test.js b/backend/__tests__/unit/models/threadingSchemaRetrofit.test.js index 3ab26c690a..3334871ace 100644 --- a/backend/__tests__/unit/models/threadingSchemaRetrofit.test.js +++ b/backend/__tests__/unit/models/threadingSchemaRetrofit.test.js @@ -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 () => { @@ -69,6 +70,7 @@ 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 @@ -76,6 +78,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('CONTROL: the old shape genuinely lacks it, so the test above measures something', async () => { @@ -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 () => { @@ -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', () => { diff --git a/backend/__tests__/unit/routes/registry.install-hosted-greeting.test.js b/backend/__tests__/unit/routes/registry.install-hosted-greeting.test.js index 5b1212c70a..f014030949 100644 --- a/backend/__tests__/unit/routes/registry.install-hosted-greeting.test.js +++ b/backend/__tests__/unit/routes/registry.install-hosted-greeting.test.js @@ -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 () => { @@ -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'); }); }); diff --git a/backend/__tests__/unit/scripts/backfill-message-author-kind.test.ts b/backend/__tests__/unit/scripts/backfill-message-author-kind.test.ts new file mode 100644 index 0000000000..75c54cda9f --- /dev/null +++ b/backend/__tests__/unit/scripts/backfill-message-author-kind.test.ts @@ -0,0 +1,67 @@ +// @ts-nocheck + +import { backfillMessageAuthorKind, MessageAuthorKindBackfillDeps } from '../../../scripts/backfill-message-author-kind'; + +const makeDeps = (overrides: Partial = {}): 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 }); + 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 }); + }); +}); diff --git a/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js b/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js index 70bc684d1c..3b679d5444 100644 --- a/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js +++ b/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js @@ -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', @@ -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, diff --git a/backend/__tests__/unit/services/stalledConnectService.test.js b/backend/__tests__/unit/services/stalledConnectService.test.js index 5fd2ca8f94..59fb98eccb 100644 --- a/backend/__tests__/unit/services/stalledConnectService.test.js +++ b/backend/__tests__/unit/services/stalledConnectService.test.js @@ -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"); diff --git a/backend/config/schema.sql b/backend/config/schema.sql index feb8ddc35d..a76fc0fc4a 100644 --- a/backend/config/schema.sql +++ b/backend/config/schema.sql @@ -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 @@ -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 diff --git a/backend/controllers/messageController.ts b/backend/controllers/messageController.ts index e1064e4f3f..10f955d977 100644 --- a/backend/controllers/messageController.ts +++ b/backend/controllers/messageController.ts @@ -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; @@ -67,6 +68,7 @@ const normalizeMongo = (m: Record): 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, diff --git a/backend/models/Message.ts b/backend/models/Message.ts index c65a195d1b..9021499718 100644 --- a/backend/models/Message.ts +++ b/backend/models/Message.ts @@ -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; @@ -31,6 +34,9 @@ const MessageSchema = new Schema({ 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 }, diff --git a/backend/models/pg/Message.ts b/backend/models/pg/Message.ts index 6715d2c280..645e8ed0ff 100644 --- a/backend/models/pg/Message.ts +++ b/backend/models/pg/Message.ts @@ -14,6 +14,7 @@ interface MessageRow { user_id: string; content: string; message_type: string; + author_kind?: 'kernel' | null; // ADR-020 D3: structured component payload (approval cards). JSONB; null // for ordinary messages. payload?: unknown; @@ -107,6 +108,7 @@ class Message { // The message the posting run is answering (agentAnswerService), handed to // the attention writer and never stored: not a column, not a reply edge. answeringMessageId: string | null = null, + authorKind: 'kernel' | null = null, ): Promise { console.log('Creating message with params:', { podId, userId, content, messageType, podIdType: typeof podId, @@ -131,7 +133,7 @@ class Message { // TWO SHAPES since @ux-lead's 56879 amendment. An ordinary in-thread post // carries NO reply edge — the composer sends thread_root_id explicitly, so // joining a thread never pings the root's author. Explicit replies and the - // backfill still derive. `COALESCE($7, )` is that precedence in + // backfill still derive. `COALESCE($8, )` is that precedence in // one line: explicit wins, derivation fills. Reconciling the two (and // rejecting a mismatch) happens in services/threadRootResolver, before // this runs — a model that silently picked a winner would hide a caller's @@ -143,23 +145,26 @@ class Message { // tier instead of only against a real server. A cast that costs nothing in // production and buys a fast test everywhere is worth writing down. const query = ` - INSERT INTO messages (pod_id, user_id, content, message_type, reply_to_message_id, payload, thread_root_id) - SELECT $1, $2, $3, $4, $5::int, $6, + INSERT INTO messages ( + pod_id, user_id, content, message_type, author_kind, reply_to_message_id, payload, thread_root_id + ) + SELECT $1, $2, $3, $4, $5, $6::int, $7, COALESCE( - $7::int, + $8::int, (SELECT COALESCE(parent.thread_root_id, parent.id) FROM messages parent - WHERE parent.id = $5::int) + WHERE parent.id = $6::int) )::int - WHERE ($5::int IS NULL - OR $1::varchar IN (SELECT target.pod_id FROM messages target WHERE target.id = $5::int)) - AND ($7::int IS NULL - OR $1::varchar IN (SELECT named.pod_id FROM messages named WHERE named.id = $7::int)) + WHERE ($6::int IS NULL + OR $1::varchar IN (SELECT target.pod_id FROM messages target WHERE target.id = $6::int)) + AND ($8::int IS NULL + OR $1::varchar IN (SELECT named.pod_id FROM messages named WHERE named.id = $8::int)) RETURNING * `; try { const result = await (pool as PgPool).query(query, [ - podId, userId, content || '', messageType, replyToMessageId || null, + podId, userId, content || '', messageType, authorKind, + replyToMessageId || null, payload == null ? null : JSON.stringify(payload), resolvedThreadRootId ?? null, ]); @@ -218,7 +223,7 @@ class Message { try { let query = ` SELECT - m.id, m.pod_id, m.user_id, m.content, m.message_type, m.payload, + m.id, m.pod_id, m.user_id, m.content, m.message_type, m.author_kind, m.payload, m.reply_to_message_id, -- MUST be selected. Late columns are easy to miss in an explicit -- projection, and this one is read by the wake path. @@ -260,7 +265,7 @@ class Message { try { const query = ` SELECT - m.id, m.pod_id, m.user_id, m.content, m.message_type, m.payload, + m.id, m.pod_id, m.user_id, m.content, m.message_type, m.author_kind, m.payload, m.reply_to_message_id, -- MUST be selected. The controller prefers THIS row over create()'s -- RETURNING * (message = populated || created) and hands it to diff --git a/backend/package.json b/backend/package.json index c3f695c2e8..811307fb68 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,6 +22,7 @@ "discord:list": "node scripts/register-discord-commands.js list", "bootstrap:clawd-bot": "node scripts/bootstrap-clawd-bot.js", "backfill:attention-items": "ts-node scripts/backfill-attention-items.ts", + "backfill:message-author-kind": "ts-node scripts/backfill-message-author-kind.ts", "migrate:task-source-ref-identity": "ts-node scripts/migrate-task-source-ref-identity.ts", "sweep:resolved-mention-attention": "ts-node scripts/sweep-resolved-mention-attention.ts", "sweep:done-task-handoffs": "ts-node scripts/sweep-done-task-handoffs.ts", diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts index 57002db3f2..3d6551596a 100644 --- a/backend/routes/registry/install.ts +++ b/backend/routes/registry/install.ts @@ -986,6 +986,7 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any podId, content: intro, metadata: { kind: 'install-intro' }, + authorKind: 'kernel', }); } } catch (introError: unknown) { diff --git a/backend/scripts/backfill-message-author-kind.ts b/backend/scripts/backfill-message-author-kind.ts new file mode 100644 index 0000000000..0fa1f3b9a4 --- /dev/null +++ b/backend/scripts/backfill-message-author-kind.ts @@ -0,0 +1,185 @@ +/** + * Mark historical stalled-connect nudges as Commonly-authored seat posts. + * + * The episode keeps the message id, while the message may live in PostgreSQL + * or in MongoDB when the availability fallback was used. The default is a dry + * run; write only with `npm run backfill:message-author-kind -- --apply`. + * Both stores are updated by message id and a retry is idempotent. + */ +/* eslint-disable no-console */ + +import mongoose from 'mongoose'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const StalledConnectEpisode = require('../models/StalledConnectEpisode'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const Message = require('../models/Message'); + +interface PgPool { + query: (sql: string, values?: unknown[]) => Promise<{ + rows: Array>; + rowCount?: number; + }>; + end: () => Promise; +} + +interface PgMessageRow { + id: string | number; + author_kind?: unknown; +} + +interface MongoMessageRow { + _id: unknown; + authorKind?: unknown; +} + +export interface MessageAuthorKindBackfillReport { + dryRun: boolean; + episodeReferences: number; + uniqueMessageReferences: number; + postgresReferences: number; + mongoReferences: number; + invalidReferences: number; + missingReferences: number; + postgresRows: number; + mongoRows: number; + alreadyKernel: number; + toBackfill: number; + appliedPostgres: number; + appliedMongo: number; +} + +export interface MessageAuthorKindBackfillDeps { + getEpisodeReferences: () => Promise; + getPostgresRows: (ids: string[]) => Promise; + getMongoRows: (ids: string[]) => Promise; + updatePostgresRows: (ids: string[]) => Promise; + updateMongoRows: (ids: string[]) => Promise; +} + +const classifyReferences = (references: string[]) => { + const unique = [...new Set(references.map((reference) => String(reference).trim()).filter(Boolean))]; + const postgres = unique.filter((id) => /^\d+$/.test(id)); + const mongo = unique.filter((id) => mongoose.Types.ObjectId.isValid(id)); + const known = new Set([...postgres, ...mongo]); + return { + unique, + postgres, + mongo, + invalid: unique.filter((id) => !known.has(id)), + }; +}; + +export async function backfillMessageAuthorKind( + deps: MessageAuthorKindBackfillDeps, + options: { apply?: boolean } = {}, +): Promise { + const references = await deps.getEpisodeReferences(); + const classified = classifyReferences(references); + const [postgresRows, mongoRows] = await Promise.all([ + deps.getPostgresRows(classified.postgres), + deps.getMongoRows(classified.mongo), + ]); + + const postgresToBackfill = postgresRows + .filter((row) => row.author_kind !== 'kernel') + .map((row) => String(row.id)); + const mongoToBackfill = mongoRows + .filter((row) => row.authorKind !== 'kernel') + .map((row) => String(row._id)); + const matched = new Set([ + ...postgresRows.map((row) => String(row.id)), + ...mongoRows.map((row) => String(row._id)), + ]); + + const report: MessageAuthorKindBackfillReport = { + dryRun: options.apply !== true, + episodeReferences: references.filter((reference) => String(reference).trim()).length, + uniqueMessageReferences: classified.unique.length, + postgresReferences: classified.postgres.length, + mongoReferences: classified.mongo.length, + invalidReferences: classified.invalid.length, + missingReferences: classified.unique.filter((id) => !matched.has(id)).length, + postgresRows: postgresRows.length, + mongoRows: mongoRows.length, + alreadyKernel: postgresRows.length - postgresToBackfill.length + + mongoRows.length - mongoToBackfill.length, + toBackfill: postgresToBackfill.length + mongoToBackfill.length, + appliedPostgres: 0, + appliedMongo: 0, + }; + + if (options.apply !== true) return report; + + report.appliedPostgres = await deps.updatePostgresRows(postgresToBackfill); + report.appliedMongo = await deps.updateMongoRows(mongoToBackfill); + return report; +} + +const productionDeps = (pool: PgPool | null): MessageAuthorKindBackfillDeps => ({ + getEpisodeReferences: async () => { + const rows = await StalledConnectEpisode.find({ + nudgeMessageId: { $type: 'string', $ne: '' }, + }).select('nudgeMessageId').lean(); + return rows.map((row: { nudgeMessageId?: unknown }) => String(row.nudgeMessageId || '')); + }, + getPostgresRows: async (ids) => { + if (!pool || ids.length === 0) return []; + const result = await pool.query( + 'SELECT id, author_kind FROM messages WHERE id = ANY($1::int[])', + [ids.map(Number)], + ); + return result.rows as unknown as PgMessageRow[]; + }, + getMongoRows: async (ids) => { + if (ids.length === 0) return []; + const objectIds = ids.map((id) => new mongoose.Types.ObjectId(id)); + return Message.find({ _id: { $in: objectIds } }) + .select('_id authorKind').lean() as Promise; + }, + updatePostgresRows: async (ids) => { + if (!pool || ids.length === 0) return 0; + const result = await pool.query( + "UPDATE messages SET author_kind = 'kernel' WHERE id = ANY($1::int[]) AND author_kind IS DISTINCT FROM 'kernel'", + [ids.map(Number)], + ); + return result.rowCount || 0; + }, + updateMongoRows: async (ids) => { + if (ids.length === 0) return 0; + const result = await Message.updateMany( + { + _id: { $in: ids.map((id) => new mongoose.Types.ObjectId(id)) }, + authorKind: { $ne: 'kernel' }, + }, + { $set: { authorKind: 'kernel' } }, + ); + return result.modifiedCount || 0; + }, +}); + +export async function main(): Promise { + const apply = process.argv.includes('--apply'); + if (!process.env.MONGO_URI) throw new Error('MONGO_URI is required'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { pool } = require('../config/db-pg') as { pool: PgPool | null }; + + await mongoose.connect(process.env.MONGO_URI); + try { + const report = await backfillMessageAuthorKind(productionDeps(pool), { apply }); + console.log(`[backfill-message-author-kind] ${JSON.stringify(report)}`); + if (!apply) { + console.log('DRY RUN — no message rows written. Re-run with --apply after deploy.'); + } + } finally { + await mongoose.disconnect(); + if (pool) await pool.end(); + } +} + +if (require.main === module) { + main().catch((error) => { + console.error('message author-kind backfill failed:', error); + process.exit(1); + }); +} diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index 4018204356..3dc7f836cf 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -186,6 +186,10 @@ interface PostMessageOptions { // the native runtime's trigger. Read by the answer rule (agentAnswerService) // only. Never stored and never a reply edge, so an answer is not threaded. answeringMessageId?: string | null; + // Server-set, never model-set: only install intros and stalled-connect + // nudges speak beside an agent seat as Commonly. Ordinary agent posts stay + // unmarked even if their metadata happens to contain a similar label. + authorKind?: 'kernel' | null; } interface PostTargetOptions { @@ -205,6 +209,7 @@ interface PostTargetOptions { skipSummaryPersistence?: boolean; noOutboundRelay?: boolean; answeringMessageId?: string | null; + authorKind?: 'kernel' | null; } interface HeartbeatFallbackOptions { @@ -237,6 +242,7 @@ interface MessageNormalized { id: unknown; content: string; messageType: string; + author_kind?: 'kernel' | null; userId: { _id: unknown; username: string; profilePicture?: string; isBot?: boolean }; username: string; isBot?: boolean; @@ -998,6 +1004,7 @@ class AgentMessageService { installationConfig = null, noOutboundRelay = false, answeringMessageId, + authorKind = null, } = options; // Typing indicator cleanup — once postMessage is entered, the runtime has @@ -1579,6 +1586,7 @@ class AgentMessageService { threadRootId, noOutboundRelay, answeringMessageId, + authorKind, }); // The first answer an agent gives the workspace owner completes "Give your @@ -1661,6 +1669,7 @@ class AgentMessageService { skipSummaryPersistence = false, noOutboundRelay = false, answeringMessageId, + authorKind = null, } = options; // The installation label belongs to this pod; the User label belongs to @@ -1724,21 +1733,22 @@ class AgentMessageService { } catch (syncErr) { console.warn('[agent-msg] PG pod backfill skipped:', (syncErr as Error).message); } - const newMessage = await (PGMessage as { + const pgMessageStore = PGMessage as { create( podId: string, userId: string, content: string, type: string, replyToMessageId?: string | null, payload?: unknown, resolvedThreadRootId?: number | null, answeringMessageId?: string | null, + authorKind?: 'kernel' | null, ): Promise>; - }).create( - String(podId), - String(agentUser._id), - content, - messageType, - replyToMessageId, - payload, - resolvedThreadRootId, - answeringMessageId, - ); + }; + const newMessage = authorKind + ? await pgMessageStore.create( + String(podId), String(agentUser._id), content, messageType, + replyToMessageId, payload, resolvedThreadRootId, answeringMessageId, authorKind, + ) + : await pgMessageStore.create( + String(podId), String(agentUser._id), content, messageType, + replyToMessageId, payload, resolvedThreadRootId, answeringMessageId, + ); // The raw INSERT row carries no reply JOIN, so a replying agent's // broadcast would lack the quoted context until reload (#646). @@ -1762,6 +1772,7 @@ class AgentMessageService { id: newMessage.id, content: newMessage.content as string, messageType: (newMessage.message_type as string) || messageType, + author_kind: (newMessage.author_kind as 'kernel' | null) ?? authorKind, replyTo, userId: { _id: agentUser._id, @@ -1798,12 +1809,25 @@ class AgentMessageService { podId, messageType, metadata, + ...(authorKind ? { authorKind } : {}), ...(payload != null ? { payload } : {}), }); await mongoMessage.save(); await mongoMessage.populate('userId', 'username profilePicture isBot'); - message = mongoMessage as MessageNormalized; + const serializedMongoMessage = typeof mongoMessage.toObject === 'function' + ? mongoMessage.toObject() + : mongoMessage; + message = { + // Mongoose documents keep schema fields in `_doc`; spreading the + // document only copies internals and drops content for broadcasts and + // installable bridge dispatches. Serialize after populate so the + // Mongo fallback matches the PostgreSQL normalized shape. The plain + // fallback keeps lightweight test doubles and alternate adapters + // compatible while real Mongoose documents take the safe path above. + ...(serializedMongoMessage as unknown as MessageNormalized), + author_kind: authorKind, + }; } AgentMessageService.logMessageLifecycle('posted', { @@ -1861,6 +1885,7 @@ class AgentMessageService { podId: String(podId), content: (message as MessageNormalized).content, messageType: (message as MessageNormalized).messageType || messageType, + author_kind: (message as MessageNormalized).author_kind ?? authorKind, userId: (message as MessageNormalized).userId || { _id: agentUser._id, username: senderDisplayName, @@ -2235,6 +2260,7 @@ class AgentMessageService { // "this server cannot say" (same convention as the frontend). reply_to_message_id: (msg.reply_to_message_id as string | number | null) ?? null, thread_root_id: (msg.thread_root_id as string | number | null) ?? null, + author_kind: (msg.author_kind as 'kernel' | null) ?? null, replyTo: (msg.replyTo as MessageNormalized['replyTo']) ?? null, }; }); @@ -2274,6 +2300,7 @@ class AgentMessageService { userId: userId || { username: 'Unknown' }, username, isBot: userId?.isBot === true, + author_kind: msg.authorKind === 'kernel' || msg.author_kind === 'kernel' ? 'kernel' : null, ...withSelf(userId?._id), createdAt: msg.createdAt as Date, }; diff --git a/backend/services/stalledConnectService.ts b/backend/services/stalledConnectService.ts index 8d154b214c..735d24ffeb 100644 --- a/backend/services/stalledConnectService.ts +++ b/backend/services/stalledConnectService.ts @@ -296,6 +296,7 @@ const nudgeOnce = async ({ podId, content, metadata: { kind: 'stalled-connect-nudge' }, + authorKind: 'kernel', }); const messageId = extractMessageId(posted); if (!messageId) { diff --git a/frontend/src/components/ChatRoom.css b/frontend/src/components/ChatRoom.css index 094939e6ff..038ac05e7f 100644 --- a/frontend/src/components/ChatRoom.css +++ b/frontend/src/components/ChatRoom.css @@ -1438,6 +1438,18 @@ body { vertical-align: middle; } +.kernel-author-badge { + background: #e8ecfb; + color: #4353b8; + font-size: 9px; + font-weight: 700; + padding: 2px 6px; + border-radius: 3px; + letter-spacing: 0.2px; + margin-left: 6px; + vertical-align: middle; +} + /* Bot message content styling */ .bot-bubble { background: linear-gradient(135deg, rgba(15, 23, 42, 0.98) 0%, rgba(30, 41, 59, 0.96) 100%) !important; diff --git a/frontend/src/components/ChatRoom.tsx b/frontend/src/components/ChatRoom.tsx index ae8501ffb9..2787310a5f 100644 --- a/frontend/src/components/ChatRoom.tsx +++ b/frontend/src/components/ChatRoom.tsx @@ -4522,6 +4522,7 @@ const ChatRoom = () => { const mappedDisplayName = agentDisplayMap.get(normalizedUsername); const matchedAgent = resolvePodAgentByIdentity(username); const isAgentMessage = Boolean(matchedAgent) || Boolean(mappedDisplayName) || isAgentUsername(username); + const isKernelAuthored = msg.author_kind === 'kernel' || msg.authorKind === 'kernel'; const displayName = mappedDisplayName || username; const messageSenderId = (msg.userId && typeof msg.userId === 'object' && msg.userId._id) || msg.user_id @@ -4620,6 +4621,9 @@ const ChatRoom = () => {
{displayName} BOT + {isKernelAuthored && ( + Commonly + )}
@@ -4701,6 +4705,9 @@ const ChatRoom = () => {
{displayName} {isAgentMessage && } + {isKernelAuthored && ( + Commonly + )}
{/* Quote bubble if this is a reply */} diff --git a/frontend/src/v2/__tests__/V2MessageRow.test.tsx b/frontend/src/v2/__tests__/V2MessageRow.test.tsx index ce5e56fa7f..1346330b6c 100644 --- a/frontend/src/v2/__tests__/V2MessageRow.test.tsx +++ b/frontend/src/v2/__tests__/V2MessageRow.test.tsx @@ -134,6 +134,34 @@ describe('V2MessageRow', () => { .toBe('[docs](https://example.com/docs) here'); }); + it('marks a Commonly-authored seat post without marking an agent post', () => { + const { rerender } = render( + + + , + ); + + expect(screen.getByLabelText('Commonly system message')).toHaveTextContent('Commonly'); + + rerender( + + + , + ); + + expect(screen.queryByLabelText('Commonly system message')).not.toBeInTheDocument(); + }); + it('resolves relative uploaded images against the configured API origin', () => { process.env.REACT_APP_API_URL = 'https://api.commonly.me'; diff --git a/frontend/src/v2/__tests__/message-grouping.test.ts b/frontend/src/v2/__tests__/message-grouping.test.ts index 16a5f4c145..a4be136d18 100644 --- a/frontend/src/v2/__tests__/message-grouping.test.ts +++ b/frontend/src/v2/__tests__/message-grouping.test.ts @@ -47,6 +47,24 @@ describe('isGroupedWithPrevious', () => { ).toBe(false); }); + it('never groups an agent row after a kernel-authored row', () => { + expect( + isGroupedWithPrevious( + msg({ author_kind: null, created_at: at(1000) }), + msg({ author_kind: 'kernel' }), + ), + ).toBe(false); + }); + + it('never groups a kernel-authored row after an agent row', () => { + expect( + isGroupedWithPrevious( + msg({ author_kind: 'kernel', created_at: at(1000) }), + msg({ author_kind: null }), + ), + ).toBe(false); + }); + it('replies keep their header — the quote needs an owner', () => { expect( isGroupedWithPrevious( diff --git a/frontend/src/v2/components/V2MessageRow.tsx b/frontend/src/v2/components/V2MessageRow.tsx index 7f7f41b828..62bf0aae8f 100644 --- a/frontend/src/v2/components/V2MessageRow.tsx +++ b/frontend/src/v2/components/V2MessageRow.tsx @@ -657,6 +657,9 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d ) : ( {author} )} + {message.author_kind === 'kernel' && ( + Commonly + )} {isLead && {t('podChat.leadBadge')}} {runtimeTag && {runtimeTag}} {time && {time}} diff --git a/frontend/src/v2/hooks/useV2PodDetail.ts b/frontend/src/v2/hooks/useV2PodDetail.ts index 2f613a85d1..3099c90d09 100644 --- a/frontend/src/v2/hooks/useV2PodDetail.ts +++ b/frontend/src/v2/hooks/useV2PodDetail.ts @@ -23,6 +23,7 @@ export interface V2Message { user_id: string; content: string; message_type: string; + author_kind?: 'kernel' | null; created_at: string; createdAt?: string; messageType?: string; diff --git a/frontend/src/v2/utils/messageGrouping.ts b/frontend/src/v2/utils/messageGrouping.ts index 34aab7e21f..658d3b2cea 100644 --- a/frontend/src/v2/utils/messageGrouping.ts +++ b/frontend/src/v2/utils/messageGrouping.ts @@ -10,6 +10,7 @@ export const GROUPING_WINDOW_MS = 3 * 60 * 1000; // Kept loose so both the live V2Message and test literals satisfy it. export interface GroupableMessage { user_id?: string | null; + author_kind?: 'kernel' | null; user?: { username?: string | null } | null; created_at?: string; replyTo?: unknown; @@ -23,6 +24,7 @@ export const isGroupedWithPrevious = ( ): boolean => { if (!prev) return false; if (!m.user_id || m.user_id !== prev.user_id) return false; + if ((m.author_kind ?? null) !== (prev.author_kind ?? null)) return false; if ((m.user?.username || '') !== (prev.user?.username || '')) return false; if (m.replyTo || m.reply_content) return false; if (m.payload?.kind || prev.payload?.kind) return false; diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 9434e14c30..d1b1b83103 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -3117,6 +3117,17 @@ body.modern-ui.v2-canvas { background: var(--v2-surface-hover); } +.v2-msg__author-kind { + display: inline-block; + height: 16px; + padding: 0 5px; + border-radius: 3px; + background: var(--v2-surface-hover); + color: var(--v2-accent-text); + font: 700 11px/16px var(--v2-font-mono); + letter-spacing: 0.02em; +} + .v2-msg__avatar-btn { appearance: none; background: transparent;