From 18e0c6338e4ffc333b8cd4afcbc1d85beb9e1f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 23 May 2026 21:04:59 -0400 Subject: [PATCH] Add structured chat delivery records --- .../migrations/025-chat-message-delivery.ts | 10 +++ AgentCompany-New/src/main/database/schema.ts | 2 + .../main/services/chat-message-structure.ts | 21 ++++++ .../src/main/services/chat-service.ts | 25 +++++-- .../src/main/services/sdlc-service.ts | 2 +- .../src/main/services/snapshot-service.ts | 1 + AgentCompany-New/src/shared/commands.ts | 1 + AgentCompany-New/src/shared/domain.ts | 11 ++++ .../tests/unit/chat-governance.test.ts | 66 +++++++++++++++++++ .../tests/unit/domain-services.test.ts | 2 +- 10 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 AgentCompany-New/src/main/database/migrations/025-chat-message-delivery.ts create mode 100644 AgentCompany-New/src/main/services/chat-message-structure.ts diff --git a/AgentCompany-New/src/main/database/migrations/025-chat-message-delivery.ts b/AgentCompany-New/src/main/database/migrations/025-chat-message-delivery.ts new file mode 100644 index 00000000..0be1791b --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/025-chat-message-delivery.ts @@ -0,0 +1,10 @@ +export const chatMessageDeliverySchema = ` + alter table chat_messages add column body_structured_json text not null default '{}'; + alter table chat_messages add column delivery_state text not null default 'sent'; + + create index if not exists idx_chat_messages_delivery_state + on chat_messages(org_id, delivery_state, created_at); + + insert into schema_migrations(version, name, applied_at) + values (25, 'chat_message_delivery', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index f42fa5a4..3838d169 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -23,6 +23,7 @@ import { providerAuthBrokerSchema } from "./migrations/021-provider-auth-broker" import { providerRateLimitObservationSchema } from "./migrations/022-provider-rate-limit-observations"; import { providerRequestRuntimeSchema } from "./migrations/023-provider-request-runtime"; import { taskRunProviderRequestSchema } from "./migrations/024-task-run-provider-request"; +import { chatMessageDeliverySchema } from "./migrations/025-chat-message-delivery"; interface Migration { version: number; @@ -55,6 +56,7 @@ const MIGRATIONS: Migration[] = [ { version: 22, name: "provider_rate_limit_observations", sql: providerRateLimitObservationSchema }, { version: 23, name: "provider_request_runtime", sql: providerRequestRuntimeSchema }, { version: 24, name: "task_run_provider_request", sql: taskRunProviderRequestSchema }, + { version: 25, name: "chat_message_delivery", sql: chatMessageDeliverySchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/chat-message-structure.ts b/AgentCompany-New/src/main/services/chat-message-structure.ts new file mode 100644 index 00000000..b81517af --- /dev/null +++ b/AgentCompany-New/src/main/services/chat-message-structure.ts @@ -0,0 +1,21 @@ +import type { ResolvedMention } from "./chat-mentions"; + +export interface ChatMessageStructureInput { + slash: { command: string; args: string } | null; + mentions: ResolvedMention[]; + attachmentFileIds: string[]; +} + +export function buildChatMessageStructure(input: ChatMessageStructureInput): string { + return JSON.stringify({ + format: "markdown", + slashInvocation: input.slash ? { command: input.slash.command, args: input.slash.args } : null, + mentions: input.mentions.map((mention) => ({ + targetType: mention.target.type, + targetId: mention.target.id, + offsetStart: mention.offsetStart, + offsetEnd: mention.offsetEnd, + })), + attachmentFileIds: input.attachmentFileIds, + }); +} diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index b479341c..c40aa0c0 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -9,10 +9,12 @@ import { createId } from "../../shared/ids"; import { ApprovalService } from "./approval-service"; import { AuditService } from "./audit-service"; import { BoardInterventionService, parseBoardCommand } from "./board-intervention-service"; +import { buildChatMessageStructure } from "./chat-message-structure"; import { recordChatMentions, resolveChatMentions, type MentionTarget, type ResolvedMention } from "./chat-mentions"; const SPACE_TYPES = new Set(["company", "department", "project", "direct"]); const SPACE_VISIBILITIES = new Set(["workspace", "restricted"]); +const RETENTION_POLICIES = new Set(["standard_365d", "restricted_365d"]); interface AttachmentCandidate { fileId: string; @@ -71,9 +73,9 @@ export class ChatService { const id = createId("spc"); const now = new Date().toISOString(); this.db.prepare(` - insert into chat_spaces(id, org_id, space_type, name, topic, visibility, created_at) - values (?, ?, ?, ?, ?, ?, ?) - `).run(id, input.orgId, input.spaceType, name, topic, input.visibility, now); + insert into chat_spaces(id, org_id, space_type, name, topic, visibility, retention_policy, created_at) + values (?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, input.orgId, input.spaceType, name, topic, input.visibility, retentionPolicy(input.retentionPolicy, input.visibility), now); const members = broadSpace ? this.resolveWorkspaceMembers(input.orgId) : explicitMembers; for (const member of members) this.addMembership(id, member, input.visibility === "restricted" ? "approver" : "member"); @@ -101,16 +103,19 @@ export class ChatService { const slash = parseSlashInvocation(body); if (slash && !ALLOWED_SLASH_COMMANDS.has(slash.command)) throw new Error(`Slash command /${slash.command} is unavailable.`); if (slash) validateSlashInvocation(slash); - const attachments = this.requireAttachments(input.orgId, input.attachmentFileIds ?? [], sender); + const attachmentFileIds = input.attachmentFileIds ?? []; + const attachments = this.requireAttachments(input.orgId, attachmentFileIds, sender); const mentions = resolveChatMentions(this.db, input.orgId, input.spaceId, body); + const structuredBody = buildChatMessageStructure({ slash, mentions, attachmentFileIds }); const id = createId("msg"); const threadId = input.threadId ?? createId("thread"); const now = new Date().toISOString(); this.db.prepare(` insert into chat_messages( - id, org_id, space_id, thread_id, sender_type, sender_id, body_md, created_at, edited_at, deleted_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + id, org_id, space_id, thread_id, sender_type, sender_id, body_md, body_structured_json, + delivery_state, created_at, edited_at, deleted_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, input.orgId, @@ -119,6 +124,8 @@ export class ChatService { input.senderType, input.senderId, body, + structuredBody, + "sent", now, null, null, @@ -479,3 +486,9 @@ function createThreadTitle(body: string): string { const firstLine = body.split(/\r?\n/, 1)[0].trim(); return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine; } + +function retentionPolicy(value: string | undefined, visibility: CreateChatSpaceInput["visibility"]): string { + const policy = value ?? (visibility === "restricted" ? "restricted_365d" : "standard_365d"); + if (!RETENTION_POLICIES.has(policy)) throw new Error("Chat retention policy is invalid."); + return policy; +} diff --git a/AgentCompany-New/src/main/services/sdlc-service.ts b/AgentCompany-New/src/main/services/sdlc-service.ts index 1abb655f..e38e62ee 100644 --- a/AgentCompany-New/src/main/services/sdlc-service.ts +++ b/AgentCompany-New/src/main/services/sdlc-service.ts @@ -262,7 +262,7 @@ export class SdlcService { { key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` }, { key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry }, { key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady }, - { key: "schema_version", passed: schemaVersion >= 24, value: schemaVersion }, + { key: "schema_version", passed: schemaVersion >= 25, value: schemaVersion }, ]; } diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index 96812256..ba660bb5 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -92,6 +92,7 @@ export class SnapshotService { taskRuns: list(this.db, "task_runs", "created_at asc"), approvals: list(this.db, "approvals", "created_at desc"), chatSpaces: list(this.db, "chat_spaces", "created_at asc"), + chatMemberships: list(this.db, "chat_memberships", "space_id asc, principal_type asc, principal_id asc"), chatMessages: list(this.db, "chat_messages", "created_at asc"), chatThreads: list(this.db, "chat_threads", "updated_at desc"), chatAttachments: list(this.db, "chat_attachments", "message_id asc"), diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index 6711a60c..470649eb 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -129,6 +129,7 @@ export interface CreateChatSpaceInput { name: string; topic: string; visibility: "workspace" | "restricted"; + retentionPolicy?: "standard_365d" | "restricted_365d"; memberEmployeeIds?: string[]; memberAgentIds?: string[]; } diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 8998b6cd..9aec2d1c 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -182,11 +182,21 @@ export interface ChatMessageRecord { senderType: "employee" | "agent" | "board" | "system"; senderId: string | null; bodyMd: string; + bodyStructuredJson: string; + deliveryState: string; createdAt: string; editedAt: string | null; deletedAt: string | null; } +export interface ChatMembershipRecord { + spaceId: string; + principalType: "employee" | "agent"; + principalId: string; + role: string; + muteState: string; +} + export interface ChatThreadRecord { id: string; orgId: string; @@ -439,6 +449,7 @@ export interface AppSnapshot { taskRuns: TaskRunRecord[]; approvals: ApprovalRecord[]; chatSpaces: ChatSpaceRecord[]; + chatMemberships: ChatMembershipRecord[]; chatMessages: ChatMessageRecord[]; chatThreads: ChatThreadRecord[]; chatAttachments: ChatAttachmentRecord[]; diff --git a/AgentCompany-New/tests/unit/chat-governance.test.ts b/AgentCompany-New/tests/unit/chat-governance.test.ts index 8352237a..d4ba9ecf 100644 --- a/AgentCompany-New/tests/unit/chat-governance.test.ts +++ b/AgentCompany-New/tests/unit/chat-governance.test.ts @@ -12,6 +12,72 @@ function createConnection(): DatabaseConnection { } describe("chat governance", () => { + test("persists memberships, retention policy, and structured delivery metadata", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const chat = new ChatService(connection.db); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + let employeeId = ""; + let agentId = ""; + let spaceId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + agentId = organization.createAgent({ + orgId, + name: "Ada Lovelace", + role: "Release reviewer", + department: "Engineering", + modelProviderId: null, + modelId: null, + budgetCents: 10000, + managerAgentId: null, + }); + spaceId = chat.createSpace({ + orgId, + spaceType: "project", + name: "release-review", + topic: "Release review", + visibility: "restricted", + memberEmployeeIds: [employeeId], + memberAgentIds: [agentId], + }); + chat.sendMessage({ + orgId, + spaceId, + threadId: null, + senderType: "employee", + senderId: employeeId, + bodyMd: "/escalate review @Ada-Lovelace release gate", + attachmentFileIds: [], + }); + }); + + const state = snapshot.load(); + const message = state.chatMessages[0]; + const structure = JSON.parse(message!.bodyStructuredJson) as { + slashInvocation: { command: string }; + mentions: Array<{ targetType: string; targetId: string }>; + }; + expect(state.chatSpaces.find((space) => space.id === spaceId)?.retentionPolicy).toBe("restricted_365d"); + expect(state.chatMemberships + .filter((membership) => membership.spaceId === spaceId) + .map((membership) => `${membership.principalType}:${membership.principalId}`) + .sort()) + .toEqual([`agent:${agentId}`, `employee:${employeeId}`]); + expect(message).toMatchObject({ deliveryState: "sent" }); + expect(structure.slashInvocation.command).toBe("escalate"); + expect(structure.mentions[0]).toMatchObject({ targetType: "agent", targetId: agentId }); + connection.close(); + }); + test("records board interventions and enforces thread control", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db); diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index e4157601..121e2bb2 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -23,7 +23,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]); connection.close(); });