diff --git a/backend/__tests__/service/agentSendFreshnessCommitFence.test.js b/backend/__tests__/service/agentSendFreshnessCommitFence.test.js index b5606a1b7c..99ba8e4082 100644 --- a/backend/__tests__/service/agentSendFreshnessCommitFence.test.js +++ b/backend/__tests__/service/agentSendFreshnessCommitFence.test.js @@ -85,4 +85,24 @@ d('agent freshness: in-flight PostgreSQL insert', () => { } }); + it('a trigger-revisioned claim line creates no send hold; ordinary peer chat still does', async () => { + const { emitSystemActionLine } = require('../../services/systemActionLineService'); + await pool.query('DELETE FROM messages WHERE pod_id = $1', [String(podId)]); + await Cursor.deleteOne({ _id: `${agentId}:${podId}` }); + const before = await Freshness.readStart(podId); + await Freshness.recordRead(agentId, podId, before, []); + await emitSystemActionLine({ podId: String(podId), actorUserId: String(peerId), + actorName: 'fence-peer', action: 'task-claimed', taskId: 'TASK-406' }); + const { rows: [line] } = await pool.query('SELECT freshness_revision FROM messages WHERE pod_id = $1', [String(podId)]); + expect(Number(line.freshness_revision)).toBeGreaterThan(before.revision); + expect(await Freshness.unseenMessages(agentId, podId)).toBeNull(); + const after = await Freshness.readStart(podId); + await Freshness.recordRead(agentId, podId, after, []); + expect((await Cursor.findById(`${agentId}:${podId}`).lean()).readRevision).toBe(after.revision); + await pool.query('INSERT INTO messages (pod_id, user_id, content) VALUES ($1, $2, $3)', + [String(podId), String(peerId), 'ordinary peer chat']); + expect(await Freshness.unseenMessages(agentId, podId)).toMatchObject({ unseenCount: 1, + messages: [{ content: 'ordinary peer chat' }] }); + }); + }); diff --git a/backend/__tests__/service/agentSendFreshnessMongoCommitFence.test.js b/backend/__tests__/service/agentSendFreshnessMongoCommitFence.test.js index f3f6645789..05a385f2c3 100644 --- a/backend/__tests__/service/agentSendFreshnessMongoCommitFence.test.js +++ b/backend/__tests__/service/agentSendFreshnessMongoCommitFence.test.js @@ -28,6 +28,19 @@ describe('agent freshness: delayed Mongo-only write', () => { await Cursor.deleteMany({}); }); + it('ignores claim lines for sends and cursor advancement but still holds ordinary peer chat', async () => { + await Message.collection.insertOne({ podId, userId: peerId, content: 'peer claimed TASK-406', + messageType: 'system', payload: { kind: 'system-action' }, createdAt: new Date(), freshnessCommittedAt: new Date(), freshnessPending: false }); + expect(await Freshness.unseenMessages(agentId, podId)).toBeNull(); + const start = await Freshness.readStart(podId); + await Freshness.recordRead(agentId, podId, start, []); + expect(await Cursor.findById(`${agentId}:${podId}`).lean()).not.toBeNull(); + await Message.collection.insertOne({ podId, userId: peerId, content: 'ordinary peer chat', + messageType: 'text', createdAt: new Date(), freshnessPending: true }); + const hold = await Freshness.unseenMessages(agentId, podId); + expect(hold).toMatchObject({ unseenCount: 1, messages: [{ content: 'ordinary peer chat' }] }); + }); + it('holds an insert whose createdAt predates a successful read but whose save finishes later', async () => { const peerMessage = new Message({ podId, diff --git a/backend/__tests__/service/tasks.claim-lease.test.js b/backend/__tests__/service/tasks.claim-lease.test.js index 7e811e0031..aed2dd9552 100644 --- a/backend/__tests__/service/tasks.claim-lease.test.js +++ b/backend/__tests__/service/tasks.claim-lease.test.js @@ -22,10 +22,14 @@ jest.mock('../../services/taskEventService', () => { const actual = jest.requireActual('../../services/taskEventService'); return { ...actual, notifyPodAgents: jest.fn().mockResolvedValue(undefined) }; }); +jest.mock('../../services/systemActionLineService', () => ({ + emitSystemActionLine: jest.fn().mockResolvedValue(undefined), +})); const express = require('express'); const request = require('supertest'); const { notifyPodAgents } = require('../../services/taskEventService'); +const { emitSystemActionLine } = require('../../services/systemActionLineService'); const Pod = require('../../models/Pod'); const Task = require('../../models/Task'); const User = require('../../models/User'); @@ -62,6 +66,7 @@ describe('POST /api/v1/tasks/:podId/:taskId/claim — lease semantics (ADR-018 D beforeEach(async () => { await clearMongoDb(); + emitSystemActionLine.mockClear(); owner = await User.create({ username: `lease-owner-${Date.now()}`, email: `lease-owner-${Date.now()}@test.com`, @@ -81,7 +86,7 @@ describe('POST /api/v1/tasks/:podId/:taskId/claim — lease semantics (ADR-018 D verified: true, isBot: true, botType: 'agent', - botMetadata: { agentName: 'claim-audit-agent', instanceId: 'default' }, + botMetadata: { agentName: 'claim-audit-agent', instanceId: 'default', displayName: 'Audit Agent' }, agentRuntimeTokens: [{ tokenHash: hash(AGENT_TOKEN), label: 'task-claim-audit', @@ -134,6 +139,11 @@ describe('POST /api/v1/tasks/:podId/:taskId/claim — lease semantics (ADR-018 D authorId: agent._id.toString(), }), ])); + expect(emitSystemActionLine).toHaveBeenCalledWith(expect.objectContaining({ + actorName: 'Audit Agent', + actorUsername: 'claim-audit-agent', + actorIsBot: true, + })); }); test('a fresh claim stamps a lease ~30 minutes out', async () => { @@ -145,6 +155,16 @@ describe('POST /api/v1/tasks/:podId/:taskId/claim — lease semantics (ADR-018 D const expires = new Date(res.body.task.claimExpiresAt).getTime(); expect(expires).toBeGreaterThanOrEqual(before + LEASE_MS - 5000); expect(expires).toBeLessThanOrEqual(Date.now() + LEASE_MS + 5000); + expect(emitSystemActionLine).toHaveBeenCalledWith({ + podId: pod._id.toString(), + actorUserId: owner._id.toString(), + actorName: owner.username, + actorUsername: owner.username, + actorAvatarUrl: null, + actorIsBot: false, + action: 'task-claimed', + taskId: 'T-1', + }); }); test('a live lease refuses a rival, naming the holder and when the lease frees', async () => { @@ -164,6 +184,7 @@ describe('POST /api/v1/tasks/:podId/:taskId/claim — lease semantics (ADR-018 D const renewed = await claim(ownerToken); expect(renewed.status).toBe(200); expect(new Date(renewed.body.task.claimExpiresAt).getTime()).toBeGreaterThan(firstExpiry); + expect(emitSystemActionLine).toHaveBeenCalledTimes(1); }); test('a lapsed lease is claimable by a peer — dead claimants never hold work forever', async () => { @@ -176,6 +197,7 @@ describe('POST /api/v1/tasks/:podId/:taskId/claim — lease semantics (ADR-018 D const res = await claim(rivalToken); expect(res.status).toBe(200); expect(res.body.task.claimedBy).toBe(rival._id.toString()); + expect(emitSystemActionLine).toHaveBeenCalledTimes(1); }); test('a RECENT legacy claim (no lease field) is still protected', async () => { diff --git a/backend/__tests__/unit/models/pg/message.systemActions.test.js b/backend/__tests__/unit/models/pg/message.systemActions.test.js new file mode 100644 index 0000000000..5d154e4153 --- /dev/null +++ b/backend/__tests__/unit/models/pg/message.systemActions.test.js @@ -0,0 +1,15 @@ +jest.mock('../../../../config/db-pg', () => ({ pool: { query: jest.fn() } })); +const { pool } = require('../../../../config/db-pg'); +const Message = require('../../../../models/pg/Message'); +beforeEach(() => { pool.query.mockReset(); pool.query.mockResolvedValue({ rows: [] }); }); +test('activity exclusion is an SQL predicate before LIMIT', async () => { + await Message.findByPodId('pod-1', 1, null, null, { excludeSystemActions: true }); + const [sql, params] = pool.query.mock.calls[0]; + expect(sql).toContain("NOT (m.message_type = 'system' AND COALESCE(m.payload->>'kind', '') = 'system-action')"); + expect(sql.indexOf("= 'system-action'")).toBeLessThan(sql.indexOf('LIMIT')); + expect(params).toEqual(['pod-1', 1]); +}); +test('ordinary pod history still includes system action rows', async () => { + await Message.findByPodId('pod-1', 1); + expect(pool.query.mock.calls[0][0]).not.toContain("= 'system-action'"); +}); diff --git a/backend/__tests__/unit/services/activityService.agentMessages.test.js b/backend/__tests__/unit/services/activityService.agentMessages.test.js index 76db0d94f4..7705418434 100644 --- a/backend/__tests__/unit/services/activityService.agentMessages.test.js +++ b/backend/__tests__/unit/services/activityService.agentMessages.test.js @@ -1,3 +1,5 @@ +jest.mock('../../../models/Message', () => ({ find: jest.fn() })); + jest.mock('../../../models/pg/Message', () => ({ findByPodId: jest.fn() })); const PGMessage = require('../../../models/pg/Message'); @@ -29,3 +31,16 @@ describe('ActivityService.getMessageActivities', () => { })]); }); }); + +const MongoMessage = require('../../../models/Message'); +test('Activity excludes system action rows before its PG and Mongo fetch limits', async () => { + PGMessage.findByPodId.mockResolvedValue([]); + const lean = jest.fn().mockResolvedValue([]); + const populate = jest.fn().mockReturnValue({ lean }); + const limit = jest.fn().mockReturnValue({ populate }); + MongoMessage.find.mockReturnValue({ sort: () => ({ limit }) }); + await ActivityService.getMessageActivities(['pod-1'], new Map(), { limit: 1 }); + expect(PGMessage.findByPodId).toHaveBeenCalledWith('pod-1', 1, null, null, { excludeSystemActions: true }); + expect(MongoMessage.find).toHaveBeenCalledWith({ podId: { $in: ['pod-1'] }, $nor: [{ messageType: 'system', 'payload.kind': 'system-action' }] }); + expect(limit).toHaveBeenCalledWith(1); +}); diff --git a/backend/__tests__/unit/services/agentSendFreshness.test.js b/backend/__tests__/unit/services/agentSendFreshness.test.js index d3bfb110d5..886eff0b4a 100644 --- a/backend/__tests__/unit/services/agentSendFreshness.test.js +++ b/backend/__tests__/unit/services/agentSendFreshness.test.js @@ -39,6 +39,7 @@ describe('agent send-time freshness', () => { expect(hold.message).toContain('continueAnyway:true'); expect(Message.countDocuments).toHaveBeenCalledWith({ podId: 'pod-id', + $nor: [{ messageType: 'system', 'payload.kind': 'system-action' }], $and: [ { $or: [{ userId: { $ne: 'agent-id' } }, { authorKind: 'kernel' }] }, { $or: [ @@ -129,4 +130,18 @@ describe('agent send-time freshness', () => { expect(pool.query.mock.calls[0][1]).toEqual(['pod-id', 4, 6, 'agent-id', [5, 6]]); expect(Cursor.updateOne).toHaveBeenCalledTimes(1); }); + + it('excludes system actions from both PostgreSQL send holds and omitted-read checks', async () => { + jest.clearAllMocks(); + process.env.PG_HOST = 'configured'; + pool.query.mockResolvedValue({ rows: [] }); + await Freshness.unseenMessages('agent-id', 'pod-id'); + await Freshness.recordRead('agent-id', 'pod-id', { at: new Date(), revision: 10 }, []); + expect(pool.query.mock.calls).toHaveLength(2); + for (const [sql] of pool.query.mock.calls) { + expect(sql).toContain("NOT (m.message_type = 'system' AND COALESCE(m.payload->>'kind', '') = 'system-action')"); + expect(sql.indexOf("= 'system-action'")).toBeLessThan(sql.indexOf('LIMIT')); + } +}); + }); diff --git a/backend/__tests__/unit/services/systemActionLineService.test.js b/backend/__tests__/unit/services/systemActionLineService.test.js new file mode 100644 index 0000000000..c24ab8da41 --- /dev/null +++ b/backend/__tests__/unit/services/systemActionLineService.test.js @@ -0,0 +1,65 @@ +const mockQuery = jest.fn(); +const mockEmit = jest.fn(); +const mockTo = jest.fn(() => ({ emit: mockEmit })); + +jest.mock('../../../config/db-pg', () => ({ + pool: { query: (...args) => mockQuery(...args) }, +})); +jest.mock('../../../config/socket', () => ({ + getIO: () => ({ to: mockTo }), +})); + +const { emitSystemActionLine } = require('../../../services/systemActionLineService'); + +beforeEach(() => { + jest.clearAllMocks(); + mockQuery.mockResolvedValue({ rows: [{ id: 42, created_at: '2026-10-23T20:00:00.000Z' }] }); +}); + +it('persists one structured system row and broadcasts the same line without a wake or pod touch', async () => { + await emitSystemActionLine({ + podId: 'pod-1', actorUserId: 'user-1', actorName: 'Forge', + actorUsername: 'forge-agent', actorAvatarUrl: '/avatars/forge.png', actorIsBot: true, + action: 'task-claimed', taskId: 'TASK-406', + }); + + expect(mockQuery).toHaveBeenCalledTimes(1); + const [sql, params] = mockQuery.mock.calls[0]; + expect(sql).toContain("'system'"); + expect(sql).not.toMatch(/UPDATE pods|attention|AgentEvent/i); + expect(params.slice(0, 3)).toEqual(['pod-1', 'user-1', 'Forge claimed TASK-406']); + expect(JSON.parse(params[3])).toEqual({ + kind: 'system-action', action: 'task-claimed', actorName: 'Forge', taskId: 'TASK-406', + }); + expect(mockTo).toHaveBeenCalledWith('pod_pod-1'); + expect(mockEmit).toHaveBeenCalledWith('newMessage', expect.objectContaining({ + id: '42', messageType: 'system', pod_id: 'pod-1', + payload: expect.objectContaining({ action: 'task-claimed', taskId: 'TASK-406' }), + user: expect.objectContaining({ username: 'forge-agent', profile_picture: '/avatars/forge.png', isBot: true }), + })); +}); + +it('executes the INSERT and reads the table-generated identity, timestamp and JSON payload back', async () => { + const { newDb } = require('pg-mem'); + const db = newDb(); + db.public.none(`CREATE TABLE messages ( + id SERIAL PRIMARY KEY, pod_id TEXT NOT NULL, user_id TEXT NOT NULL, + content TEXT NOT NULL, message_type TEXT NOT NULL, payload JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); + const { Pool } = db.adapters.createPg(); + const pool = new Pool(); + mockQuery.mockImplementation((sql, params) => pool.query(sql, params)); + try { + await emitSystemActionLine({ podId: 'pod-1', actorUserId: 'user-1', actorName: 'Persisted Forge', action: 'task-claimed', taskId: 'TASK-406' }); + const { rows: [row] } = await pool.query('SELECT * FROM messages'); + expect(row.id).toBe(1); + expect(row.created_at).toBeInstanceOf(Date); + expect(row.message_type).toBe('system'); + expect(row.payload).toEqual({ kind: 'system-action', action: 'task-claimed', actorName: 'Persisted Forge', taskId: 'TASK-406' }); + expect(mockEmit).toHaveBeenCalledWith('newMessage', expect.objectContaining({ id: String(row.id), created_at: row.created_at, payload: row.payload })); + } finally { + mockQuery.mockReset(); + await pool.end(); + } +}); diff --git a/backend/controllers/podController.ts b/backend/controllers/podController.ts index 53414ba9e0..9b6a27b82c 100644 --- a/backend/controllers/podController.ts +++ b/backend/controllers/podController.ts @@ -273,6 +273,7 @@ exports.getAllPods = async (req: any, res: any) => { FROM messages m LEFT JOIN users u ON u._id = m.user_id WHERE m.pod_id = ANY($1::text[]) + AND NOT (m.message_type = 'system' AND COALESCE(m.payload->>'kind', '') = 'system-action') ORDER BY m.pod_id, m.created_at DESC `; const result = await pool.query(sql, [podIds]); diff --git a/backend/models/pg/Message.ts b/backend/models/pg/Message.ts index b6cfbad0f8..05f7c9b6f2 100644 --- a/backend/models/pg/Message.ts +++ b/backend/models/pg/Message.ts @@ -245,6 +245,7 @@ class Message { // derivation in create()). This is what makes agent-side thread reads // cheaper than paging the whole pod (TASK-052's cue depends on it). threadRootId: string | null = null, + options: { excludeSystemActions?: boolean } = {}, ): Promise { // Defense-in-depth clamp at the data layer: callers should pass a bounded // limit, but a stray unbounded value must never turn into a million-row @@ -270,6 +271,9 @@ class Message { WHERE m.pod_id = $1 `; const queryParams: unknown[] = [podId]; + if (options.excludeSystemActions) { + query += " AND NOT (m.message_type = 'system' AND COALESCE(m.payload->>'kind', '') = 'system-action')"; + } if (before) { query += ' AND m.created_at < $2'; queryParams.push(before); diff --git a/backend/routes/tasksApi.ts b/backend/routes/tasksApi.ts index 32c9a2e0c0..c4749addab 100644 --- a/backend/routes/tasksApi.ts +++ b/backend/routes/tasksApi.ts @@ -22,6 +22,10 @@ const AgentSendFreshness = require('../services/agentSendFreshness'); // eslint-disable-next-line global-require const { emitTaskUpdated, notifyPodAgents } = require('../services/taskEventService'); // eslint-disable-next-line global-require +const { emitSystemActionLine } = require('../services/systemActionLineService'); +// eslint-disable-next-line global-require +const { resolveAgentDisplayLabel } = require('../services/agentIdentityService'); +// eslint-disable-next-line global-require const { recordTaskAttention, resolveTaskAttention } = require('../services/attentionItemService'); // eslint-disable-next-line global-require const { parseAssignee, toSingleLineTitle } = require('../utils/taskFieldNormalization'); @@ -78,11 +82,11 @@ interface AuthReq { // `username` is here because the sweep can write it into `lapsedFrom` // (provenance falls back through holder.label = assignee || username || // claimedBy), so the restore path must be able to match on it. - user?: { id?: string; _id?: unknown; isBot?: boolean; username?: string; botMetadata?: { instanceId?: string; agentName?: string } }; + user?: { id?: string; _id?: unknown; isBot?: boolean; username?: string; profilePicture?: string; botMetadata?: { displayName?: string; instanceId?: string; agentName?: string } }; // Same reason as `user.username` above: `agentRuntimeAuth` puts the bot's // User row here, and its username is what the sweep writes into // `lapsedFrom` for an agent holder. - agentUser?: { _id?: unknown; username?: string }; + agentUser?: { _id?: unknown; username?: string; profilePicture?: string; isBot?: boolean; botMetadata?: { displayName?: string; instanceId?: string; agentName?: string } }; params?: Record; query?: Record; body?: Record; @@ -690,6 +694,22 @@ router.post('/:podId/:taskId/claim', rateLimit({ && (preClaim.claimedBy === claimedBy || preClaim.lapsedFrom === claimedBy); if (!isSameHolderContinuation) { notifyAgents(req, podId, task, 'updated', preClaim); + const actorUser = req.agentUser || req.user; + const actorName = actorUser?.isBot + ? resolveAgentDisplayLabel(actorUser, author) + : actorUser?.username || author; + // The claim CAS has committed. This quiet timeline projection is + // best-effort and never changes the claim's HTTP result. + void emitSystemActionLine({ + podId: String(podId), + actorUserId: String(userId), + actorName, + actorUsername: actorUser?.username || author, + actorAvatarUrl: actorUser?.profilePicture || null, + actorIsBot: !!actorUser?.isBot, + action: 'task-claimed', + taskId: String(task.taskId || taskId), + }).catch((error: Error) => console.warn('[tasks] system action line failed:', error.message)); } return res.json({ task }); } catch (err) { diff --git a/backend/services/activityService.ts b/backend/services/activityService.ts index 90b09e991f..bc80c89705 100644 --- a/backend/services/activityService.ts +++ b/backend/services/activityService.ts @@ -368,7 +368,10 @@ class ActivityService { // recap still commonly-bot-only because the working pods still never // made the fetch window). const { rows } = await pool.query( - 'SELECT pod_id, MAX(created_at) AS latest FROM messages WHERE pod_id = ANY($1::text[]) GROUP BY pod_id ORDER BY latest DESC', + `SELECT pod_id, MAX(created_at) AS latest FROM messages + WHERE pod_id = ANY($1::text[]) + AND NOT (message_type = 'system' AND COALESCE(payload->>'kind', '') = 'system-action') + GROUP BY pod_id ORDER BY latest DESC`, [ids], ); const ranked = rows.map((r) => String(r.pod_id)); @@ -716,7 +719,13 @@ class ActivityService { // the vacuum. (Verified live: findByPodId(oid) -> 0 rows, // findByPodId(String(oid)) -> rows.) const podMessagesList = await Promise.all( - podIds.slice(0, 12).map((podId) => (PGMessage as { findByPodId(id: unknown, limit: number): Promise }).findByPodId(String(podId), limit)), + podIds.slice(0, 12).map((podId) => (PGMessage as { + findByPodId( + id: unknown, count: number, cursor: string | null, thread: string | null, + queryOptions: { excludeSystemActions: boolean }, + ): Promise; + }) + .findByPodId(String(podId), limit, before || null, null, { excludeSystemActions: true })), ); messages = podMessagesList.flat() as Array>; } catch (e) { @@ -725,7 +734,10 @@ class ActivityService { } if (messages.length === 0 && Message) { - const query: Record = { podId: { $in: podIds } }; + const query: Record = { + podId: { $in: podIds }, + $nor: [{ messageType: 'system', 'payload.kind': 'system-action' }], + }; if (before) { query.createdAt = { $lt: new Date(before) }; } diff --git a/backend/services/agentSendFreshness.ts b/backend/services/agentSendFreshness.ts index 5be5a4fee9..f0215c8595 100644 --- a/backend/services/agentSendFreshness.ts +++ b/backend/services/agentSendFreshness.ts @@ -77,6 +77,7 @@ async function omittedMessages( SELECT ${count ? 'COUNT(*)::integer AS skipped_count' : '1'} FROM messages m WHERE m.pod_id = $1 AND m.freshness_revision > $2 AND m.freshness_revision <= $3 AND (m.user_id IS DISTINCT FROM $4 OR m.author_kind = 'kernel') + AND NOT (m.message_type = 'system' AND COALESCE(m.payload->>'kind', '') = 'system-action') AND NOT (m.id = ANY($5::int[])) ${count ? '' : 'LIMIT 1'} `, [String(podId), previous.readRevision ?? -1, start.revision, ownId, ids]); @@ -85,6 +86,7 @@ async function omittedMessages( const observed = observedIds.filter((id) => /^[a-f\d]{24}$/i.test(id)); const query = { podId, + $nor: [{ messageType: 'system', 'payload.kind': 'system-action' }], _id: { $nin: observed }, $and: [ { $or: [{ userId: { $ne: ownId } }, { authorKind: 'kernel' }] }, @@ -130,6 +132,7 @@ async function unseenMessages(agentUserId: unknown, podId: unknown): Promise $2 AND (m.user_id IS DISTINCT FROM $3 OR m.author_kind = 'kernel') + AND NOT (m.message_type = 'system' AND COALESCE(m.payload->>'kind', '') = 'system-action') ORDER BY m.freshness_revision DESC, m.id DESC LIMIT 3 `, [String(podId), afterRevision, ownId]); unseenCount = Number(result.rows[0]?.unseen_count || 0); @@ -158,6 +161,7 @@ async function unseenMessages(agentUserId: unknown, podId: unknown): Promise { const query = { podId, + $nor: [{ messageType: 'system', 'payload.kind': 'system-action' }], $and: [ { $or: [{ userId: { $ne: ownId } }, { authorKind: 'kernel' }] }, { $or: [ diff --git a/backend/services/systemActionLineService.ts b/backend/services/systemActionLineService.ts new file mode 100644 index 0000000000..99b8ac257c --- /dev/null +++ b/backend/services/systemActionLineService.ts @@ -0,0 +1,73 @@ +// System action lines are kernel-authored facts. They bypass the ordinary +// message writer so they cannot create mention attention, wake agents, or +// advance a pod's human-message unread timestamp. +export interface SystemActionLine { + podId: string; + actorUserId: string; + actorName: string; + actorUsername: string; + actorAvatarUrl: string | null; + actorIsBot: boolean; + action: 'task-claimed'; + taskId: string; +} + +interface PgMessageRow { + id: number | string; + created_at: Date | string; +} + +export async function emitSystemActionLine(line: SystemActionLine): Promise { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const { pool } = require('../config/db-pg') as { + pool: { query: (sql: string, params: unknown[]) => Promise<{ rows: PgMessageRow[] }> } | null; + }; + if (!pool) throw new Error('System action lines require PostgreSQL'); + + const content = `${line.actorName} claimed ${line.taskId}`; + const payload = { + kind: 'system-action', + action: line.action, + actorName: line.actorName, + taskId: line.taskId, + }; + const { rows } = await pool.query( + `INSERT INTO messages (pod_id, user_id, content, message_type, payload) + VALUES ($1, $2, $3, 'system', $4::jsonb) + RETURNING id, created_at`, + [line.podId, line.actorUserId, content, JSON.stringify(payload)], + ); + const row = rows[0]; + if (!row) throw new Error('System action line was not persisted'); + + try { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const { getIO } = require('../config/socket') as { + getIO: () => { to: (room: string) => { emit: (event: string, data: unknown) => void } } | null; + }; + getIO()?.to(`pod_${line.podId}`).emit('newMessage', { + id: String(row.id), + _id: String(row.id), + pod_id: line.podId, + podId: line.podId, + user_id: line.actorUserId, + content, + message_type: 'system', + messageType: 'system', + payload, + created_at: row.created_at, + createdAt: row.created_at, + user: { + username: line.actorUsername, + profile_picture: line.actorAvatarUrl, + isBot: line.actorIsBot, + }, + thread_root_id: null, + reply_to_message_id: null, + }); + } catch (error) { + // The row is already durable; reconnect fetches it. A socket failure + // cannot turn a successful claim into an HTTP error or a duplicate line. + console.warn('[system-action] socket emit failed:', (error as Error).message); + } +} diff --git a/frontend/design-system/preview/system-action-line.html b/frontend/design-system/preview/system-action-line.html new file mode 100644 index 0000000000..eef1eef418 --- /dev/null +++ b/frontend/design-system/preview/system-action-line.html @@ -0,0 +1,32 @@ + + + + + + Pod system action line + + + + +
+
Maya
Forge, can you take the system lines?
+
ForgeclaimedTASK-406
+
Forge
I’ve got it.
+
+ + diff --git a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts index ee9cfdfdca..32b43d5e48 100644 --- a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts +++ b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts @@ -43,7 +43,7 @@ const OPAQUE_PLACEHOLDERS = [ 'connector', 'user', 'workspace', 'channel', 'tool', 'tools', 'member', 'agents', 'os', // common.aboutPage receives a page title, currently Latin, not a count. 'adapter', 'title', - 'holder', + 'holder', 'actor', // Runtime ownership names the visible blocking Pods and installers. 'blockers', 'harness', 'effort', 'model', ]; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 1e675e9d3b..96bf713716 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -485,6 +485,9 @@ "firstTask": { "addFailed": "Couldn’t add the task. Try again." }, + "systemActions": { + "taskClaimedVerb": "claimed" + }, "starts": { "label": "Ways to start" }, diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 39236f71ef..b644f2d440 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -485,6 +485,9 @@ "firstTask": { "addFailed": "添加任务失败,请重试。" }, + "systemActions": { + "taskClaimedVerb": "认领了" + }, "starts": { "label": "开始方式" }, diff --git a/frontend/src/v2/__tests__/V2MessageRow.test.tsx b/frontend/src/v2/__tests__/V2MessageRow.test.tsx index b8e4f6afbf..b28bdb5eb8 100644 --- a/frontend/src/v2/__tests__/V2MessageRow.test.tsx +++ b/frontend/src/v2/__tests__/V2MessageRow.test.tsx @@ -304,4 +304,24 @@ describe('V2MessageRow', () => { expect(screen.getByText('Pixel and codex started a DM').closest('.v2-syscard')).toHaveClass('v2-syscard--action'); }); + + it('renders a structured task claim as a quiet line without message actions', () => { + render( + + + , + ); + const line = screen.getByText('Forge').closest('.v2-system-action-line'); + expect(line).toBeInTheDocument(); + expect(line).toHaveTextContent('ForgeclaimedTASK-406'); + expect(line?.querySelector('.v2-system-action-line__object')).toHaveTextContent('TASK-406'); + expect(line?.querySelector('.v2-avatar')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 975c0bbe0a..04fdca3720 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1055,6 +1055,21 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-msg.v2-msg--system')).toContain('display: block'); }); + test('system actions align with message text and keep the avatar and time in their own slots', () => { + const rule = ruleBody(v2, '.v2-system-action-line'); + expect(rule).toContain('display: grid'); + expect(rule).toContain('grid-template-columns: 16px minmax(0, max-content) auto minmax(0, 1fr) auto'); + expect(rule).toContain('margin-left: 36px'); + expect(rule).toContain('width: calc(100% - 36px)'); + expect(ruleBody(v2, '.v2-thread .v2-system-action-line .v2-avatar')).toContain('width: 16px'); + expect(ruleBody(v2, '.v2-system-action-line__action')).toContain('grid-column: 3;'); + expect(ruleBody(v2, '.v2-system-action-line time')).toContain('grid-column: 5'); + expect(ruleBody(v2, '.v2-system-action-line__name')).toContain('color: var(--v2-text-primary)'); + expect(ruleBody(v2, '.v2-system-action-line__verb')).toContain('color: var(--v2-text-tertiary)'); + expect(ruleBody(v2, '.v2-system-action-line__object')).toContain('var(--v2-font-mono)'); + expect(ruleBody(v2, '.v2-system-action-line time')).toContain('var(--v2-font-mono)'); + }); + test('the feedback popover overrides the legacy dark MUI paper theme', () => { // body.modern-ui .MuiPaper-root has enough specificity to beat a two-class // selector and turn portaled v2 popovers dark. Keep the compound Paper diff --git a/frontend/src/v2/components/V2MessageRow.tsx b/frontend/src/v2/components/V2MessageRow.tsx index c6caaba2f4..734a1a523f 100644 --- a/frontend/src/v2/components/V2MessageRow.tsx +++ b/frontend/src/v2/components/V2MessageRow.tsx @@ -463,6 +463,33 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d const handleAuthorClick = isClickable ? () => onAuthorClick?.(rawUsername) : undefined; const time = formatRelativeTime(message.created_at); + // Kernel system actions have a structured payload and no message actions, + // avatar, reply affordance or mention semantics. Legacy system messages + // continue through the existing renderer below. + if ((message.message_type === 'system' || message.messageType === 'system') + && message.payload?.kind === 'system-action' + && message.payload.action === 'task-claimed') { + const actorLabel = overriddenDisplay || message.payload.actorName || author; + return ( +
+ + {actorLabel} + + {t('podChat.systemActions.taskClaimedVerb')} + {message.payload.taskId || ''} + + {time && } +
+ ); + } + if (decision) { return (
diff --git a/frontend/src/v2/hooks/useV2PodDetail.ts b/frontend/src/v2/hooks/useV2PodDetail.ts index 3342066812..a0396e9496 100644 --- a/frontend/src/v2/hooks/useV2PodDetail.ts +++ b/frontend/src/v2/hooks/useV2PodDetail.ts @@ -86,6 +86,9 @@ export interface V2Message { // the `...raw` spread — declared here so consumers can read it typed. payload?: { kind?: string; + action?: string; + actorName?: string; + taskId?: string; elements?: unknown[]; // kind 'starts': offers rendered as buttons under the message (first // session, direction B). A plain start sends its label as the person's own diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 6239cfedb7..56bca25a64 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -2721,6 +2721,64 @@ body.modern-ui.v2-canvas { color: var(--v2-text-secondary); } +/* TASK-406: quiet, non-interactive kernel action line in the pod timeline. */ +.v2-system-action-line { + box-sizing: border-box; + width: calc(100% - 36px); + margin-left: 36px; + display: grid; + grid-template-columns: 16px minmax(0, max-content) auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 4px 0; + font: 400 13px/16px var(--v2-font); +} + +.v2-thread .v2-system-action-line .v2-avatar { + width: 16px; + height: 16px; + border-width: 1px; + font: 650 11px/1 var(--v2-font-mono); + letter-spacing: 0; + text-indent: 0; +} + +.v2-system-action-line__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--v2-text-primary); + font-weight: 500; +} + +.v2-system-action-line__action { + grid-column: 3; + display: flex; + gap: 5px; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.v2-system-action-line__verb { + color: var(--v2-text-tertiary); +} + +.v2-system-action-line__object { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--v2-text-tertiary); + font: 400 11px/16px var(--v2-font-mono); +} + +.v2-system-action-line time { + grid-column: 5; + color: var(--v2-text-placeholder); + font: 400 11px/16px var(--v2-font-mono); +} + /* §3.8 system event card — replaces the regular author/avatar bubble for commonly-bot announcements like "Pixel and codex started a DM". One-row, chrome-light, with a primary CTA that navigates internally. */