From 69837bd50cdf75578c5a8ac717ffc397181eabed Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Mon, 13 Jul 2026 16:52:45 +0800 Subject: [PATCH] feat: redesign Feishu project console --- AGENTS.md | 4 + .../0017-agent-session-is-provider-bound.md | 22 +- .../migration.sql | 65 ++ .../migration.sql | 41 ++ .../migration.sql | 31 + .../migration.sql | 30 + .../migration.sql | 25 + hub/prisma/schema.prisma | 132 ++-- hub/src/agent/configuration.ts | 45 +- hub/src/agent/models.ts | 4 +- hub/src/agent/runner.ts | 3 + hub/src/deployment/agent-config-cli.ts | 10 +- hub/src/deployment/bootstrap-silo.ts | 1 + hub/src/feishu/messageBatcher.ts | 10 +- hub/src/feishu/projectConsole.ts | 322 +++++++++ hub/src/feishu/projectOnboardingCard.ts | 300 ++++++++- hub/src/feishu/slashCommands.ts | 416 +++--------- hub/src/feishu/trigger.ts | 389 ++++++++--- hub/src/feishu/triggerQueue.ts | 3 + hub/src/projectOnboarding.ts | 34 +- hub/src/settings/runtime.ts | 7 +- hub/test/integration/admin-explorer.test.ts | 7 +- .../integration/agent-configuration.test.ts | 39 +- .../integration/agent-runtime-config.test.ts | 16 +- .../feishu-binding-lifecycle.test.ts | 7 +- hub/test/integration/helpers.ts | 48 +- .../integration/project-onboarding.test.ts | 24 + hub/test/integration/silo-bootstrap.test.ts | 7 +- hub/test/integration/trigger.test.ts | 614 +++++++----------- hub/test/unit/feishu-reactions.test.ts | 2 +- hub/test/unit/slash-commands.test.ts | 12 +- hub/test/unit/trigger-queue.test.ts | 2 + spec/Spec/Prelude.lean | 3 + spec/Spec/System/ProjectGroup.lean | 41 +- 34 files changed, 1738 insertions(+), 978 deletions(-) create mode 100644 hub/prisma/migrations/20260713170000_project_group_selected_role/migration.sql create mode 100644 hub/prisma/migrations/20260713173000_project_group_role_tenant_scope/migration.sql create mode 100644 hub/prisma/migrations/20260713174500_agent_role_default_invariant/migration.sql create mode 100644 hub/prisma/migrations/20260713175000_agent_role_tenant_immutable/migration.sql create mode 100644 hub/prisma/migrations/20260713175500_organization_requires_default_role/migration.sql create mode 100644 hub/src/feishu/projectConsole.ts diff --git a/AGENTS.md b/AGENTS.md index feffcab..5691183 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,10 @@ 控制面与 Docker adapter 后置(见 ADR-0025)。 - Agent role 与 skill 是 Organization-scoped 动态运行配置:role 组合 model、system prompt、 tools 与已安装 skill;skill 版本进入 content-addressed 持久存储,run 只读加载所选快照。 + 每个 Organization 必须且只能有一个启用中的默认 role;新建群绑定从该默认值初始化,之后 + `ProjectGroupBinding` 持久化群内当前 role,run 在接纳时冻结该 role。飞书公开 slash 协议只含 + `/project`、`/usage`、`/help`;role、会话、目录操作走 `/project` 卡片,Claude 原生 + `/compact` 只能由卡片动作以未经包装的精确 prompt 转发。 `settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成 运行时能力(见 ADR-0018)。 - 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号 diff --git a/docs/adr/0017-agent-session-is-provider-bound.md b/docs/adr/0017-agent-session-is-provider-bound.md index 63db306..2c7228c 100644 --- a/docs/adr/0017-agent-session-is-provider-bound.md +++ b/docs/adr/0017-agent-session-is-provider-bound.md @@ -34,8 +34,12 @@ provider runtime cursor needed to continue a conversation. For Claude Code SDK, that cursor is the `result.session_id`; store it in `AgentSession.metadata` as `claudeSessionId` and pass it back to the next `query()` call as `options.resume`. Role is part of the session binding because role prompts and -tool surfaces can differ even when the underlying model is the same; `/draft` -and `/review` must not resume the same Claude runtime cursor by accident. +tool surfaces can differ even when the underlying model is the same. A Feishu +project group's active binding selects one Organization role for ordinary +messages. Switching that selection routes future messages to the selected +role's own session; it never mutates or merges provider cursors across roles. +Work freezes the selected role when accepted so queued requests cannot drift +after a later switch. Role definitions are Organization-scoped runtime data. A role bundle selects its default model, system prompt, tool allowlist and installed Agent skill @@ -47,6 +51,20 @@ a Hub release or process restart. A change to the role's execution surface the next run cannot resume a provider context created under stale instructions; label and ordering-only changes preserve conversational continuity. +Exactly one active role per Organization is the default used when a project +group is first bound. The default is configuration data, not a hard-coded role +name. Roles are selected through the Hub project console; role ids are not +public slash commands. A project participant may change the group's shared +selection only when both `agent.trigger` for the project and `role.trigger` for +the target role authorize that actor. The selection affects every participant's +future messages, while already accepted work keeps its frozen role. + +Hub slash commands are a closed control-plane protocol. Unknown commands fail +explicitly and are never downgraded to Agent text. Claude SDK session commands +are invoked only through typed Hub actions. In particular, compaction resumes +the selected role session and sends the exact `/compact` prompt without +prepending Feishu context. + Environment variables: ``` ANTHROPIC_BASE_URL=https://openrouter.ai/api diff --git a/hub/prisma/migrations/20260713170000_project_group_selected_role/migration.sql b/hub/prisma/migrations/20260713170000_project_group_selected_role/migration.sql new file mode 100644 index 0000000..984c93a --- /dev/null +++ b/hub/prisma/migrations/20260713170000_project_group_selected_role/migration.sql @@ -0,0 +1,65 @@ +-- ADR-0017: roles are selected through the project-group control plane, not +-- through slash-command names. Existing alpha Organizations keep draft as +-- their configured default during migration; runtime code no longer hard-codes it. +ALTER TABLE "OrganizationAgentRole" + ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false; + +-- An Organization without any role was valid in the previous schema (the +-- runtime failed closed later). Preserve the old alpha baseline so every +-- existing binding can acquire a selected role during this migration. +INSERT INTO "OrganizationAgentRole" ( + "id", "organizationId", "roleId", "label", "sortOrder", "isDefault", "updatedAt" +) +SELECT organization."id" || ':agent-role:draft', organization."id", 'draft', '草稿', 10, true, CURRENT_TIMESTAMP +FROM "Organization" organization +WHERE NOT EXISTS ( + SELECT 1 FROM "OrganizationAgentRole" role + WHERE role."organizationId" = organization."id" AND role."disabledAt" IS NULL +); + +WITH ranked AS ( + SELECT "id", row_number() OVER ( + PARTITION BY "organizationId" + ORDER BY CASE WHEN "roleId" = 'draft' THEN 0 ELSE 1 END, "sortOrder", "roleId", "id" + ) AS position + FROM "OrganizationAgentRole" + WHERE "disabledAt" IS NULL +) +UPDATE "OrganizationAgentRole" role +SET "isDefault" = (ranked.position = 1) +FROM ranked +WHERE role."id" = ranked."id"; + +CREATE UNIQUE INDEX "OrganizationAgentRole_one_active_default_per_org" + ON "OrganizationAgentRole"("organizationId") + WHERE "isDefault" = true AND "disabledAt" IS NULL; + +ALTER TABLE "ProjectGroupBinding" + ADD COLUMN "selectedAgentRoleId" TEXT; + +UPDATE "ProjectGroupBinding" binding +SET "selectedAgentRoleId" = role."id" +FROM "Project" project +JOIN "OrganizationAgentRole" role + ON role."organizationId" = project."organizationId" + AND role."isDefault" = true + AND role."disabledAt" IS NULL +WHERE binding."projectId" = project."id"; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM "ProjectGroupBinding" WHERE "selectedAgentRoleId" IS NULL) THEN + RAISE EXCEPTION 'cannot migrate project-group bindings without an active default Agent role'; + END IF; +END $$; + +ALTER TABLE "ProjectGroupBinding" + ALTER COLUMN "selectedAgentRoleId" SET NOT NULL; + +CREATE INDEX "ProjectGroupBinding_selectedAgentRoleId_idx" + ON "ProjectGroupBinding"("selectedAgentRoleId"); + +ALTER TABLE "ProjectGroupBinding" + ADD CONSTRAINT "ProjectGroupBinding_selectedAgentRoleId_fkey" + FOREIGN KEY ("selectedAgentRoleId") REFERENCES "OrganizationAgentRole"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/hub/prisma/migrations/20260713173000_project_group_role_tenant_scope/migration.sql b/hub/prisma/migrations/20260713173000_project_group_role_tenant_scope/migration.sql new file mode 100644 index 0000000..4cd03c3 --- /dev/null +++ b/hub/prisma/migrations/20260713173000_project_group_role_tenant_scope/migration.sql @@ -0,0 +1,41 @@ +-- ADR-0017 / ADR-0020: enforce the selected role's Organization at the +-- database boundary. A role primary key alone cannot prove tenant scope. +ALTER TABLE "ProjectGroupBinding" + ADD COLUMN "organizationId" TEXT; + +UPDATE "ProjectGroupBinding" binding +SET "organizationId" = project."organizationId" +FROM "Project" project +WHERE project."id" = binding."projectId"; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM "ProjectGroupBinding" WHERE "organizationId" IS NULL) THEN + RAISE EXCEPTION 'cannot tenant-scope project-group binding without a project Organization'; + END IF; +END $$; + +ALTER TABLE "ProjectGroupBinding" + ALTER COLUMN "organizationId" SET NOT NULL; + +CREATE UNIQUE INDEX "Project_organizationId_id_key" + ON "Project"("organizationId", "id"); + +ALTER TABLE "ProjectGroupBinding" + DROP CONSTRAINT "ProjectGroupBinding_projectId_fkey", + DROP CONSTRAINT "ProjectGroupBinding_selectedAgentRoleId_fkey"; + +ALTER TABLE "ProjectGroupBinding" + ADD CONSTRAINT "ProjectGroupBinding_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "ProjectGroupBinding_organizationId_projectId_fkey" + FOREIGN KEY ("organizationId", "projectId") REFERENCES "Project"("organizationId", "id") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "ProjectGroupBinding_organizationId_selectedAgentRoleId_fkey" + FOREIGN KEY ("organizationId", "selectedAgentRoleId") + REFERENCES "OrganizationAgentRole"("organizationId", "id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +CREATE INDEX "ProjectGroupBinding_organizationId_idx" + ON "ProjectGroupBinding"("organizationId"); diff --git a/hub/prisma/migrations/20260713174500_agent_role_default_invariant/migration.sql b/hub/prisma/migrations/20260713174500_agent_role_default_invariant/migration.sql new file mode 100644 index 0000000..bc8400f --- /dev/null +++ b/hub/prisma/migrations/20260713174500_agent_role_default_invariant/migration.sql @@ -0,0 +1,31 @@ +-- ADR-0017: once an Organization starts configuring roles, every committed +-- state must contain exactly one active default. The deferred trigger permits +-- an atomic default switch while rejecting zero-default transitions. +CREATE FUNCTION cph_enforce_agent_role_default() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + target_organization_id TEXT := COALESCE(NEW."organizationId", OLD."organizationId"); + active_default_count INTEGER; +BEGIN + IF NOT EXISTS (SELECT 1 FROM "Organization" WHERE "id" = target_organization_id) THEN + RETURN NULL; + END IF; + + SELECT count(*) INTO active_default_count + FROM "OrganizationAgentRole" + WHERE "organizationId" = target_organization_id + AND "isDefault" = true + AND "disabledAt" IS NULL; + + IF active_default_count <> 1 THEN + RAISE EXCEPTION 'Organization % must have exactly one active default Agent role; found %', + target_organization_id, active_default_count; + END IF; + RETURN NULL; +END; +$$; + +CREATE CONSTRAINT TRIGGER "OrganizationAgentRole_exactly_one_active_default" +AFTER INSERT OR UPDATE OR DELETE ON "OrganizationAgentRole" +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW EXECUTE FUNCTION cph_enforce_agent_role_default(); diff --git a/hub/prisma/migrations/20260713175000_agent_role_tenant_immutable/migration.sql b/hub/prisma/migrations/20260713175000_agent_role_tenant_immutable/migration.sql new file mode 100644 index 0000000..d421245 --- /dev/null +++ b/hub/prisma/migrations/20260713175000_agent_role_tenant_immutable/migration.sql @@ -0,0 +1,30 @@ +-- Organization-owned role configuration cannot be re-parented. Besides being +-- a tenant boundary, immutability ensures the deferred default-role invariant +-- checks the same Organization before and after an update. +CREATE OR REPLACE FUNCTION cph_enforce_agent_role_default() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + target_organization_id TEXT := COALESCE(NEW."organizationId", OLD."organizationId"); + active_default_count INTEGER; +BEGIN + IF TG_OP = 'UPDATE' AND NEW."organizationId" <> OLD."organizationId" THEN + RAISE EXCEPTION 'OrganizationAgentRole.organizationId is immutable'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM "Organization" WHERE "id" = target_organization_id) THEN + RETURN NULL; + END IF; + + SELECT count(*) INTO active_default_count + FROM "OrganizationAgentRole" + WHERE "organizationId" = target_organization_id + AND "isDefault" = true + AND "disabledAt" IS NULL; + + IF active_default_count <> 1 THEN + RAISE EXCEPTION 'Organization % must have exactly one active default Agent role; found %', + target_organization_id, active_default_count; + END IF; + RETURN NULL; +END; +$$; diff --git a/hub/prisma/migrations/20260713175500_organization_requires_default_role/migration.sql b/hub/prisma/migrations/20260713175500_organization_requires_default_role/migration.sql new file mode 100644 index 0000000..c703701 --- /dev/null +++ b/hub/prisma/migrations/20260713175500_organization_requires_default_role/migration.sql @@ -0,0 +1,25 @@ +-- ADR-0017's default-role function is total over Organizations. Enforce the +-- other side of the invariant when an Organization itself is created. +CREATE FUNCTION cph_enforce_organization_default_role() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + active_default_count INTEGER; +BEGIN + SELECT count(*) INTO active_default_count + FROM "OrganizationAgentRole" + WHERE "organizationId" = NEW."id" + AND "isDefault" = true + AND "disabledAt" IS NULL; + + IF active_default_count <> 1 THEN + RAISE EXCEPTION 'Organization % must have exactly one active default Agent role; found %', + NEW."id", active_default_count; + END IF; + RETURN NULL; +END; +$$; + +CREATE CONSTRAINT TRIGGER "Organization_requires_active_default_role" +AFTER INSERT ON "Organization" +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW EXECUTE FUNCTION cph_enforce_organization_default_role(); diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 82a990d..b7707e8 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -45,8 +45,9 @@ model Organization { feishuApplicationConnection OrganizationFeishuApplicationConnection? agentSkills OrganizationAgentSkill[] agentRoles OrganizationAgentRole[] - auditEntries AuditEntry[] @relation("organizationAudit") - projectSearchDocuments ProjectSearchDocument[] + projectGroupBindings ProjectGroupBinding[] + auditEntries AuditEntry[] @relation("organizationAudit") + projectSearchDocuments ProjectSearchDocument[] @@index([status]) } @@ -116,12 +117,14 @@ model OrganizationAgentRole { systemPrompt String? tools Json? sortOrder Int @default(0) + isDefault Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt disabledAt DateTime? - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - skillBindings OrganizationAgentRoleSkill[] + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + skillBindings OrganizationAgentRoleSkill[] + selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole") @@unique([organizationId, roleId]) @@unique([organizationId, id]) @@ -132,10 +135,10 @@ model OrganizationAgentRole { /// gives stable skill listing and prompt discovery order for a role bundle. model OrganizationAgentRoleSkill { organizationId String - agentRoleId String - agentSkillId String - sortOrder Int @default(0) - createdAt DateTime @default(now()) + agentRoleId String + agentSkillId String + sortOrder Int @default(0) + createdAt DateTime @default(now()) role OrganizationAgentRole @relation(fields: [organizationId, agentRoleId], references: [organizationId, id], onDelete: Cascade) skill OrganizationAgentSkill @relation(fields: [organizationId, agentSkillId], references: [organizationId, id], onDelete: Cascade) @@ -169,16 +172,16 @@ model User { platformRoles PlatformRoleAssignment[] organizationMemberships OrganizationMembership[] - createdProjects Project[] @relation("projectCreator") - requestedRuns AgentRun[] @relation("runRequester") - heldLocks ProjectAgentLock[] @relation("lockHolder") - feishuBindings ProjectGroupBinding[] @relation("bindingCreator") + createdProjects Project[] @relation("projectCreator") + requestedRuns AgentRun[] @relation("runRequester") + heldLocks ProjectAgentLock[] @relation("lockHolder") + feishuBindings ProjectGroupBinding[] @relation("bindingCreator") teamMemberships TeamMembership[] externalPrincipalMemberships ExternalPrincipalMembership[] - permissionGrants PermissionGrant[] @relation("grantCreator") - roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator") - auditEntries AuditEntry[] @relation("auditActor") - providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator") + permissionGrants PermissionGrant[] @relation("grantCreator") + roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator") + auditEntries AuditEntry[] @relation("auditActor") + providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator") feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator") feishuIdentities FeishuUserIdentity[] } @@ -197,7 +200,7 @@ model OrganizationFeishuApplicationConnection { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) secretVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersions") activeSecretVersion FeishuApplicationCredentialVersion? @relation("activeFeishuCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict) userIdentities FeishuUserIdentity[] @@ -229,19 +232,19 @@ model FeishuUserIdentity { /// ADR-0024: all Feishu app material, including provider-local app/bot ids, is /// inside one immutable authenticated envelope version. model FeishuApplicationCredentialVersion { - id String @id @default(cuid()) + id String @id @default(cuid()) connectionId String version Int - envelopeVersion Int @default(1) + envelopeVersion Int @default(1) keyId String envelope Json createdByUserId String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) retiredAt DateTime? connection OrganizationFeishuApplicationConnection @relation("feishuCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade) activeFor OrganizationFeishuApplicationConnection? @relation("activeFeishuCredentialVersion") - createdBy User? @relation("feishuCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) + createdBy User? @relation("feishuCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) @@unique([connectionId, version]) @@index([connectionId, retiredAt]) @@ -288,14 +291,14 @@ enum OrganizationConnectionStatus { /// per immutable version. keyId/envelopeVersion are redacted rotation metadata; /// all provider URL/token/API-key fields remain inside envelope ciphertext. model ProviderCredentialVersion { - id String @id @default(cuid()) + id String @id @default(cuid()) connectionId String version Int - envelopeVersion Int @default(1) + envelopeVersion Int @default(1) keyId String envelope Json createdByUserId String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) retiredAt DateTime? connection OrganizationProviderConnection @relation("providerCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade) @@ -444,14 +447,14 @@ enum FolderKind { } model Folder { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String parentId String? name String kind FolderKind @default(REGULAR) - sortKey String @default("") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + sortKey String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt archivedAt DateTime? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) @@ -475,18 +478,19 @@ model Project { updatedAt DateTime @updatedAt archivedAt DateTime? - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) - createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) + createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) groupBindings ProjectGroupBinding[] agentSessions AgentSession[] agentRuns AgentRun[] agentLock ProjectAgentLock? - roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants") - auditEntries AuditEntry[] @relation("projectAudit") - fileChanges AgentFileChange[] @relation("projectFileChanges") + roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants") + auditEntries AuditEntry[] @relation("projectAudit") + fileChanges AgentFileChange[] @relation("projectFileChanges") searchDocument ProjectSearchDocument? + @@unique([organizationId, id]) @@index([organizationId, archivedAt]) @@index([folderId, archivedAt]) @@index([archivedAt]) @@ -496,16 +500,16 @@ model Project { /// triggers keep it synchronized with Project and Folder mutations; Project /// remains the source of truth and authorization remains outside this table. model ProjectSearchDocument { - projectId String @id - organizationId String - name String - code String? - normalizedCode String - normalizedName String - breadcrumb String + projectId String @id + organizationId String + name String + code String? + normalizedCode String + normalizedName String + breadcrumb String normalizedBreadcrumb String - normalizedSearchText String - updatedAt DateTime @updatedAt + normalizedSearchText String + updatedAt DateTime @updatedAt project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) @@ -517,19 +521,25 @@ model ProjectSearchDocument { /// (1:1). Historical archived bindings are retained for audit; partial unique /// indexes in migrations enforce one active binding per project and per chat. model ProjectGroupBinding { - id String @id @default(cuid()) - projectId String - chatId String - createdByUserId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - archivedAt DateTime? + id String @id @default(cuid()) + organizationId String + projectId String + chatId String + createdByUserId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + archivedAt DateTime? + selectedAgentRoleId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + project Project @relation(fields: [organizationId, projectId], references: [organizationId, id], onDelete: Cascade) + createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) + selectedRole OrganizationAgentRole @relation("selectedAgentRole", fields: [organizationId, selectedAgentRoleId], references: [organizationId, id], onDelete: Restrict) + @@index([organizationId]) @@index([projectId, archivedAt]) @@index([chatId, archivedAt]) + @@index([selectedAgentRoleId]) } // --- AgentRun, session, lock (ADR-0002, 0017) ----------------------------- @@ -727,17 +737,17 @@ model RoleTriggerGrant { /// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type, /// actor, timestamp, details are OPEN. This table mirrors that: `runId` is the model AuditEntry { - id String @id @default(cuid()) - runId String? - projectId String? + id String @id @default(cuid()) + runId String? + projectId String? organizationId String? - actorUserId String? - action String - metadata Json - createdAt DateTime @default(now()) + actorUserId String? + action String + metadata Json + createdAt DateTime @default(now()) - actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull) - project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull) + actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull) + project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull) organization Organization? @relation("organizationAudit", fields: [organizationId], references: [id], onDelete: SetNull) @@index([runId]) diff --git a/hub/src/agent/configuration.ts b/hub/src/agent/configuration.ts index 3c16fd1..598d7d3 100644 --- a/hub/src/agent/configuration.ts +++ b/hub/src/agent/configuration.ts @@ -89,6 +89,7 @@ export class OrganizationAgentConfiguration { readonly systemPrompt?: string | null | undefined; readonly tools?: readonly string[] | null | undefined; readonly sortOrder?: number | undefined; + readonly isDefault?: boolean | undefined; }): Promise<{ readonly id: string; readonly roleId: string }> { await this.requireActiveOrganization(input.organizationId); if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`); @@ -107,8 +108,22 @@ export class OrganizationAgentConfiguration { return this.prisma.$transaction(async (tx) => { const previous = await tx.organizationAgentRole.findUnique({ where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } }, - select: { defaultModel: true, systemPrompt: true, tools: true }, + select: { defaultModel: true, systemPrompt: true, tools: true, isDefault: true }, }); + const currentDefault = await tx.organizationAgentRole.findFirst({ + where: { organizationId: input.organizationId, isDefault: true, disabledAt: null }, + select: { id: true }, + }); + const effectiveIsDefault = input.isDefault ?? previous?.isDefault ?? (currentDefault === null); + if (input.isDefault === false && previous?.isDefault === true) { + throw new Error("cannot unset the active default role without selecting a replacement"); + } + if (effectiveIsDefault) { + await tx.organizationAgentRole.updateMany({ + where: { organizationId: input.organizationId, isDefault: true }, + data: { isDefault: false }, + }); + } const role = await tx.organizationAgentRole.upsert({ where: { organizationId_roleId: { @@ -124,6 +139,7 @@ export class OrganizationAgentConfiguration { systemPrompt: normalizeOptionalText(input.systemPrompt), tools: createTools, sortOrder, + isDefault: effectiveIsDefault, }, update: { label, @@ -131,6 +147,7 @@ export class OrganizationAgentConfiguration { ...(input.systemPrompt !== undefined ? { systemPrompt: normalizeOptionalText(input.systemPrompt) } : {}), ...(updateTools !== undefined ? { tools: updateTools } : {}), sortOrder, + isDefault: effectiveIsDefault, disabledAt: null, }, select: { id: true, roleId: true }, @@ -140,6 +157,12 @@ export class OrganizationAgentConfiguration { (input.systemPrompt !== undefined && normalizeOptionalText(input.systemPrompt) !== previous.systemPrompt) || (input.tools !== undefined && JSON.stringify(input.tools) !== JSON.stringify(previous.tools)) ); + const activeDefaultCount = await tx.organizationAgentRole.count({ + where: { organizationId: input.organizationId, isDefault: true, disabledAt: null }, + }); + if (activeDefaultCount !== 1) { + throw new Error(`organization ${input.organizationId} must have exactly one active default role`); + } if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]); await tx.auditEntry.create({ data: { @@ -154,6 +177,8 @@ export class OrganizationAgentConfiguration { : normalizeOptionalText(input.systemPrompt) !== null, tools: input.tools === undefined ? "unchanged" : input.tools === null ? "all" : [...input.tools], sortOrder, + isDefault: effectiveIsDefault, + defaultExplicit: input.isDefault !== undefined, }, }, }); @@ -237,14 +262,26 @@ async function archiveRoleSessions( roleIds: readonly string[], ): Promise { if (roleIds.length === 0) return; - await tx.agentSession.updateMany({ + const sessions = await tx.agentSession.findMany({ where: { roleId: { in: [...new Set(roleIds)] }, - archivedAt: null, project: { organizationId }, }, - data: { archivedAt: new Date() }, + select: { id: true, archivedAt: true, metadata: true }, }); + const archivedAt = new Date(); + for (const session of sessions) { + const metadata = typeof session.metadata === "object" && session.metadata !== null && !Array.isArray(session.metadata) + ? session.metadata as Prisma.JsonObject + : {}; + await tx.agentSession.update({ + where: { id: session.id }, + data: { + ...(session.archivedAt === null ? { archivedAt } : {}), + metadata: { ...metadata, userResumable: false }, + }, + }); + } } function nonEmpty(value: string, label: string): string { diff --git a/hub/src/agent/models.ts b/hub/src/agent/models.ts index 3987c70..89b0070 100644 --- a/hub/src/agent/models.ts +++ b/hub/src/agent/models.ts @@ -12,8 +12,8 @@ * A named role preset — the full per-run agent bundle. Roles are **data**, not * a code enum: admin/teachers define them (ADR-0017: role-based routing is * product config, not a spec invariant). `roleId` is an opaque string and - * doubles as the slash-command name (`/draft ...`) — the registry holds the - * role set, so new roles are added by configuration, not by editing code. + * is selected through the project console — the registry holds the role set, + * so new roles are added by configuration, not by editing code. * * A role bundles everything that distinguishes one agent persona from another: * model, system prompt, and the tool surface (files / cph / feishu / skills / diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index 586ead0..ba47e96 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -293,6 +293,9 @@ export async function runAgent(req: RunRequest): Promise { case "result": { const result = message as SDKResultMessage; sdkSessionId = result.session_id; + if (result.subtype === "success" && fullText === "" && typeof result.result === "string") { + fullText = result.result; + } costUsd = Number.isFinite(result.total_cost_usd) ? result.total_cost_usd : undefined; if (result.subtype !== "success") { error = `result_${result.subtype}`; diff --git a/hub/src/deployment/agent-config-cli.ts b/hub/src/deployment/agent-config-cli.ts index c1dc35e..9091209 100644 --- a/hub/src/deployment/agent-config-cli.ts +++ b/hub/src/deployment/agent-config-cli.ts @@ -47,6 +47,7 @@ async function main(argv: readonly string[]): Promise { : {}), ...(tools !== undefined ? { tools } : {}), ...(sortOrderRaw !== undefined ? { sortOrder: integer(sortOrderRaw, "sort-order") } : {}), + ...(options.has("default") ? { isDefault: boolean(options.get("default")!, "default") } : {}), }); console.log(JSON.stringify(role)); return; @@ -79,6 +80,7 @@ async function main(argv: readonly string[]): Promise { systemPromptConfigured: role.systemPrompt !== null, tools: role.tools, disabled: role.disabledAt !== null, + default: role.isDefault, skills: role.skillBindings.map((binding) => ({ name: binding.skill.name, version: binding.skill.version, @@ -138,10 +140,16 @@ function integer(raw: string, name: string): number { return value; } +function boolean(raw: string, name: string): boolean { + if (raw === "true") return true; + if (raw === "false") return false; + throw new Error(`--${name} must be true or false`); +} + function printHelp(): void { console.log(`Usage: agent-config install-skill --organization ORG --source DIR --version VERSION - agent-config upsert-role --organization ORG --role ID --label LABEL [--model MODEL] [--system-prompt-file FILE] [--tools-json JSON] [--sort-order N] + agent-config upsert-role --organization ORG --role ID --label LABEL [--model MODEL] [--system-prompt-file FILE] [--tools-json JSON] [--sort-order N] [--default true|false] agent-config set-role-skills --organization ORG --role ID --skills name,name agent-config list --organization ORG agent-config verify-store --organization ORG`); diff --git a/hub/src/deployment/bootstrap-silo.ts b/hub/src/deployment/bootstrap-silo.ts index d33adda..e4bb51b 100644 --- a/hub/src/deployment/bootstrap-silo.ts +++ b/hub/src/deployment/bootstrap-silo.ts @@ -233,6 +233,7 @@ async function initializeSilo( roleId: "draft", label: "草稿", sortOrder: 10, + isDefault: true, }, { organizationId: input.organization.id, diff --git a/hub/src/feishu/messageBatcher.ts b/hub/src/feishu/messageBatcher.ts index 59afb37..a43e98f 100644 --- a/hub/src/feishu/messageBatcher.ts +++ b/hub/src/feishu/messageBatcher.ts @@ -47,8 +47,8 @@ export class MessageBatcher { this.extendedDebounceMs = options.extendedDebounceMs ?? DEFAULT_OPTIONS.extendedDebounceMs; } - async enqueue(chatId: string, senderOpenId: string, text: string): Promise { - const key = messageBatchKey(chatId, senderOpenId); + async enqueue(chatId: string, senderOpenId: string, text: string, discriminator?: string): Promise { + const key = messageBatchKey(chatId, senderOpenId, discriminator); await this.runSerial(key, async () => { const existing = this.pending.get(key); const batch = @@ -120,6 +120,8 @@ export class MessageBatcher { } } -export function messageBatchKey(chatId: string, senderOpenId: string): string { - return `${chatId}:${senderOpenId}`; +export function messageBatchKey(chatId: string, senderOpenId: string, discriminator?: string): string { + return discriminator === undefined + ? `${chatId}:${senderOpenId}` + : `${chatId}:${senderOpenId}:${discriminator}`; } diff --git a/hub/src/feishu/projectConsole.ts b/hub/src/feishu/projectConsole.ts new file mode 100644 index 0000000..e9a8133 --- /dev/null +++ b/hub/src/feishu/projectConsole.ts @@ -0,0 +1,322 @@ +import { Prisma, type PrismaClient } from "@prisma/client"; +import { lockActiveProjectOrganization } from "../org/status.js"; + +export interface ProjectConsoleState { + readonly organizationId: string; + readonly projectId: string; + readonly projectName: string; + readonly folderId: string | null; + readonly breadcrumb: string; + readonly selectedRole: { readonly id: string; readonly roleId: string; readonly label: string }; + readonly roles: readonly { readonly id: string; readonly roleId: string; readonly label: string }[]; + readonly currentSession: { + readonly id: string; + readonly title: string | null; + readonly updatedAt: Date; + readonly runCount: number; + readonly sdkSessionReady: boolean; + } | null; +} + +export interface FolderDestinationPage { + readonly folderId: string | null; + readonly parentFolderId: string | null; + readonly breadcrumb: string; + readonly childFolders: readonly { readonly id: string; readonly name: string }[]; +} + +export async function browseFolderDestinations( + prisma: PrismaClient, + input: { readonly organizationId: string; readonly folderId: string | null }, +): Promise { + const folder = input.folderId === null ? null : await prisma.folder.findFirst({ + where: { id: input.folderId, organizationId: input.organizationId, archivedAt: null }, + select: { id: true, parentId: true }, + }); + if (input.folderId !== null && folder === null) throw new Error(`active folder not found: ${input.folderId}`); + const childFolders = await prisma.folder.findMany({ + where: { + organizationId: input.organizationId, + parentId: input.folderId, + archivedAt: null, + kind: { not: "SYSTEM_INBOX" }, + }, + orderBy: [{ sortKey: "asc" }, { name: "asc" }, { id: "asc" }], + select: { id: true, name: true }, + }); + return { + folderId: input.folderId, + parentFolderId: folder?.parentId ?? null, + breadcrumb: input.folderId === null ? "根目录" : await folderBreadcrumb(prisma, input.folderId), + childFolders, + }; +} + +export async function loadProjectConsole( + prisma: PrismaClient, + input: { readonly projectId: string; readonly chatId: string }, +): Promise { + const binding = await prisma.projectGroupBinding.findFirst({ + where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null }, + select: { + selectedRole: { select: { id: true, roleId: true, label: true, disabledAt: true, organizationId: true } }, + project: { + select: { + id: true, + name: true, + folderId: true, + organizationId: true, + archivedAt: true, + organization: { + select: { + status: true, + agentRoles: { + where: { disabledAt: null }, + orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }], + select: { id: true, roleId: true, label: true }, + }, + }, + }, + }, + }, + }, + }); + if (binding === null) throw new Error("project console requires an active binding to this chat"); + const { project, selectedRole } = binding; + if (project.archivedAt !== null) throw new Error(`project ${project.id} is archived`); + if (project.organization.status !== "ACTIVE") { + throw new Error(`organization ${project.organizationId} is ${project.organization.status}`); + } + if (selectedRole.disabledAt !== null || selectedRole.organizationId !== project.organizationId) { + throw new Error("project group selected role is unavailable or cross-Organization"); + } + const model = await selectedRoleModel(prisma, project.organizationId, selectedRole.id); + const currentSession = await prisma.agentSession.findFirst({ + where: { + projectId: project.id, + roleId: selectedRole.roleId, + ...(model === null ? {} : { model }), + archivedAt: null, + }, + orderBy: { updatedAt: "desc" }, + select: { id: true, title: true, updatedAt: true, metadata: true, _count: { select: { runs: true } } }, + }); + return { + organizationId: project.organizationId, + projectId: project.id, + projectName: project.name, + folderId: project.folderId, + breadcrumb: project.folderId === null ? "根目录" : await folderBreadcrumb(prisma, project.folderId), + selectedRole: { id: selectedRole.id, roleId: selectedRole.roleId, label: selectedRole.label }, + roles: project.organization.agentRoles, + currentSession: currentSession === null ? null : { + id: currentSession.id, + title: currentSession.title, + updatedAt: currentSession.updatedAt, + runCount: currentSession._count.runs, + sdkSessionReady: hasClaudeSessionId(currentSession.metadata), + }, + }; +} + +function hasClaudeSessionId(metadata: unknown): boolean { + if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) return false; + const value = (metadata as Record)["claudeSessionId"]; + return typeof value === "string" && value !== ""; +} + +function isUserResumable(metadata: unknown): boolean { + return typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) && + (metadata as Record)["userResumable"] === true; +} + +function userResumableMetadata(metadata: unknown, value: boolean): Prisma.InputJsonObject { + const base = typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) + ? metadata as Prisma.JsonObject + : {}; + return { ...base, userResumable: value }; +} + +export async function selectProjectGroupRole( + prisma: PrismaClient, + input: { + readonly projectId: string; + readonly chatId: string; + readonly agentRoleId: string; + readonly actorUserId: string; + }, +): Promise { + await prisma.$transaction(async (tx) => { + await lockActiveProjectOrganization(tx, input.projectId); + const binding = await tx.projectGroupBinding.findFirst({ + where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null }, + select: { id: true, project: { select: { organizationId: true } } }, + }); + if (binding === null) throw new Error("role selection requires an active binding to this chat"); + const role = await tx.organizationAgentRole.findFirst({ + where: { + id: input.agentRoleId, + organizationId: binding.project.organizationId, + disabledAt: null, + }, + select: { id: true, roleId: true }, + }); + if (role === null) throw new Error(`active role not found: ${input.agentRoleId}`); + await tx.projectGroupBinding.update({ + where: { id: binding.id }, + data: { selectedAgentRoleId: role.id }, + }); + await tx.auditEntry.create({ + data: { + projectId: input.projectId, + actorUserId: input.actorUserId, + action: "project_group.role_selected", + metadata: { chatId: input.chatId, roleId: role.roleId }, + }, + }); + }); +} + +export async function archiveCurrentRoleSession( + prisma: PrismaClient, + input: { readonly projectId: string; readonly chatId: string; readonly actorUserId: string }, +): Promise { + return prisma.$transaction(async (tx) => { + await lockActiveProjectOrganization(tx, input.projectId); + const activeRun = await tx.agentRun.findFirst({ + where: { projectId: input.projectId, status: { in: ["ACTIVE", "WAITING_FOR_USER"] } }, + select: { id: true }, + }); + if (activeRun !== null) throw new Error(`cannot archive a session while run ${activeRun.id} is active`); + const binding = await tx.projectGroupBinding.findFirst({ + where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null }, + select: { selectedRole: { select: { roleId: true } } }, + }); + if (binding === null) throw new Error("session operation requires an active binding to this chat"); + const session = await tx.agentSession.findFirst({ + where: { projectId: input.projectId, roleId: binding.selectedRole.roleId, archivedAt: null }, + orderBy: { updatedAt: "desc" }, + select: { id: true, metadata: true }, + }); + if (session !== null) { + await tx.agentSession.update({ + where: { id: session.id }, + data: { archivedAt: new Date(), metadata: userResumableMetadata(session.metadata, true) }, + }); + } + await tx.auditEntry.create({ + data: { + projectId: input.projectId, + actorUserId: input.actorUserId, + action: "agent_session.new_requested", + metadata: { chatId: input.chatId, roleId: binding.selectedRole.roleId, archivedSessionId: session?.id ?? null }, + }, + }); + return session !== null; + }); +} + +export async function listRoleSessionHistory( + prisma: PrismaClient, + input: { readonly projectId: string; readonly chatId: string }, +): Promise { + const binding = await prisma.projectGroupBinding.findFirst({ + where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null }, + select: { selectedRole: { select: { roleId: true } } }, + }); + if (binding === null) throw new Error("session history requires an active binding to this chat"); + const sessions = await prisma.agentSession.findMany({ + where: { projectId: input.projectId, roleId: binding.selectedRole.roleId, archivedAt: { not: null } }, + orderBy: { updatedAt: "desc" }, + take: 10, + select: { id: true, title: true, updatedAt: true, metadata: true, _count: { select: { runs: true } } }, + }); + return sessions.filter((session) => isUserResumable(session.metadata)).map((session) => ({ + id: session.id, + title: session.title, + updatedAt: session.updatedAt, + runCount: session._count.runs, + })); +} + +export async function resumeRoleSession( + prisma: PrismaClient, + input: { + readonly projectId: string; + readonly chatId: string; + readonly sessionId: string; + readonly actorUserId: string; + }, +): Promise { + await prisma.$transaction(async (tx) => { + await lockActiveProjectOrganization(tx, input.projectId); + const activeRun = await tx.agentRun.findFirst({ + where: { projectId: input.projectId, status: { in: ["ACTIVE", "WAITING_FOR_USER"] } }, + select: { id: true }, + }); + if (activeRun !== null) throw new Error(`cannot resume a session while run ${activeRun.id} is active`); + const binding = await tx.projectGroupBinding.findFirst({ + where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null }, + select: { selectedRole: { select: { roleId: true } } }, + }); + if (binding === null) throw new Error("session resume requires an active binding to this chat"); + const target = await tx.agentSession.findFirst({ + where: { id: input.sessionId, projectId: input.projectId, roleId: binding.selectedRole.roleId, archivedAt: { not: null } }, + select: { id: true, provider: true, model: true, metadata: true }, + }); + if (target === null || !isUserResumable(target.metadata)) { + throw new Error("user-resumable archived session not found for the selected role"); + } + const activeSessions = await tx.agentSession.findMany({ + where: { + projectId: input.projectId, + roleId: binding.selectedRole.roleId, + provider: target.provider, + model: target.model, + archivedAt: null, + }, + select: { id: true, metadata: true }, + }); + const archivedAt = new Date(); + for (const session of activeSessions) { + await tx.agentSession.update({ + where: { id: session.id }, + data: { archivedAt, metadata: userResumableMetadata(session.metadata, true) }, + }); + } + await tx.agentSession.update({ + where: { id: target.id }, + data: { archivedAt: null, metadata: userResumableMetadata(target.metadata, false) }, + }); + await tx.auditEntry.create({ + data: { + projectId: input.projectId, + actorUserId: input.actorUserId, + action: "agent_session.resumed", + metadata: { chatId: input.chatId, roleId: binding.selectedRole.roleId, sessionId: target.id }, + }, + }); + }); +} + +async function selectedRoleModel( + prisma: PrismaClient, + organizationId: string, + roleId: string, +): Promise { + const role = await prisma.organizationAgentRole.findFirst({ + where: { id: roleId, organizationId, disabledAt: null }, + select: { defaultModel: true }, + }); + if (role === null) throw new Error(`selected role not found: ${roleId}`); + return role.defaultModel; +} + +async function folderBreadcrumb(prisma: PrismaClient, folderId: string): Promise { + const rows = await prisma.$queryRaw>(Prisma.sql` + SELECT cph_folder_breadcrumb(${folderId}) AS breadcrumb + `); + const breadcrumb = rows[0]?.breadcrumb; + if (breadcrumb === undefined) throw new Error(`failed to resolve folder breadcrumb: ${folderId}`); + return breadcrumb; +} diff --git a/hub/src/feishu/projectOnboardingCard.ts b/hub/src/feishu/projectOnboardingCard.ts index ea2df9c..bc09df2 100644 --- a/hub/src/feishu/projectOnboardingCard.ts +++ b/hub/src/feishu/projectOnboardingCard.ts @@ -5,12 +5,27 @@ export type ProjectOnboardingView = | { readonly mode: "browse"; readonly result: ProjectFolderPage }; export interface ProjectOnboardingActionValue { - readonly action: "create_project_from_chat" | "bind_project" | "browse_folder" | "search_page" | "rename_project"; + readonly action: + | "create_project_from_chat" + | "bind_project" + | "browse_folder" + | "search_page" + | "rename_project" + | "select_agent_role" + | "new_agent_session" + | "show_session_history" + | "resume_agent_session" + | "compact_agent_session" + | "browse_move_destination" + | "move_project" + | "create_folder"; readonly organization_id: string; readonly project_id?: string | undefined; readonly folder_id?: string | undefined; readonly search_query?: string | undefined; readonly page?: number | undefined; + readonly agent_role_id?: string | undefined; + readonly session_id?: string | undefined; } export function buildUnboundChatOnboardingCard(params: { @@ -62,49 +77,165 @@ export function buildProjectManagementCard(params: { readonly projectId: string; readonly projectName: string; readonly title?: string | undefined; + readonly breadcrumb?: string | undefined; + readonly selectedRole?: { readonly id: string; readonly roleId: string; readonly label: string } | undefined; + readonly roles?: readonly { readonly id: string; readonly roleId: string; readonly label: string }[] | undefined; + readonly currentSession?: { + readonly id: string; + readonly title: string | null; + readonly updatedAt: Date; + readonly runCount: number; + readonly sdkSessionReady: boolean; + } | null | undefined; + readonly canManageProject?: boolean | undefined; + readonly canCreateFolder?: boolean | undefined; }): Record { + const elements: unknown[] = [{ + tag: "markdown", + content: [ + `当前项目: **${escapeMarkdown(params.projectName)}**`, + `所在目录: **${escapeMarkdown(params.breadcrumb ?? "根目录")}**`, + params.selectedRole === undefined + ? "当前角色: **未配置**" + : `当前角色: **${escapeMarkdown(params.selectedRole.label)}**`, + sessionSummary(params.currentSession), + ].join("\n"), + }]; + const roles = params.roles ?? []; + if (roles.length > 0) { + elements.push({ tag: "markdown", content: "**切换角色**" }); + for (let index = 0; index < roles.length; index += 5) { + elements.push({ + tag: "action", + actions: roles.slice(index, index + 5).map((role) => actionButton( + role.id === params.selectedRole?.id ? `✓ ${role.label}` : role.label, + { + action: "select_agent_role", + organization_id: params.organizationId, + project_id: params.projectId, + agent_role_id: role.id, + }, + role.id === params.selectedRole?.id ? "primary" : "default", + )), + }); + } + } + const sessionActions = [ + actionButton("新开会话", { + action: "new_agent_session", organization_id: params.organizationId, project_id: params.projectId, + }), + actionButton("历史会话", { + action: "show_session_history", organization_id: params.organizationId, project_id: params.projectId, + }), + ]; + if (params.currentSession?.sdkSessionReady === true) { + sessionActions.push(actionButton("压缩上下文", { + action: "compact_agent_session", organization_id: params.organizationId, project_id: params.projectId, + })); + } + elements.push( + { tag: "markdown", content: "**当前角色会话**" }, + { tag: "action", actions: sessionActions }, + ); + if (params.canManageProject === true) { + elements.push( + { tag: "markdown", content: "**项目管理**" }, + { tag: "action", actions: [actionButton("移动项目", { + action: "browse_move_destination", + organization_id: params.organizationId, + project_id: params.projectId, + page: 1, + })] }, + renameProjectForm(params), + ); + } + if (params.canCreateFolder === true) elements.push(createFolderForm(params)); return { config: { wide_screen_mode: true }, header: { title: { tag: "plain_text", content: params.title ?? "项目管理" }, template: "green", }, - elements: [ - { tag: "markdown", content: `当前项目: **${escapeMarkdown(params.projectName)}**` }, + elements, + }; +} + +export function buildSessionHistoryCard(params: { + readonly organizationId: string; + readonly projectId: string; + readonly roleLabel: string; + readonly sessions: readonly { + readonly id: string; + readonly title: string | null; + readonly updatedAt: Date; + readonly runCount: number; + }[]; +}): Record { + const elements: unknown[] = [{ tag: "markdown", content: `角色: **${escapeMarkdown(params.roleLabel)}**` }]; + if (params.sessions.length === 0) elements.push({ tag: "markdown", content: "没有可恢复的历史会话。" }); + for (const session of params.sessions) { + elements.push( { - tag: "form", - name: "project_rename_form", - fallback: { - tag: "fallback_text", - text: { tag: "plain_text", content: "请升级飞书客户端后修改项目名称。" }, - }, - elements: [ - { - tag: "input", - name: "project_name", - required: true, - max_length: 100, - default_value: params.projectName, - placeholder: { tag: "plain_text", content: "请输入项目名称" }, - }, - { - tag: "button", - name: "project_rename_submit", - text: { tag: "plain_text", content: "保存项目名称" }, - type: "primary", - complex_interaction: true, - action_type: "form_submit", - value: { - project_onboarding: { - action: "rename_project", - organization_id: params.organizationId, - project_id: params.projectId, - }, - }, - }, - ], + tag: "markdown", + content: `**${escapeMarkdown(session.title ?? "未命名会话")}**\n${session.runCount} runs · ${formatDate(session.updatedAt)}`, }, - ], + { tag: "action", actions: [actionButton("恢复此会话", { + action: "resume_agent_session", + organization_id: params.organizationId, + project_id: params.projectId, + session_id: session.id, + }, "primary")] }, + ); + } + return { + config: { wide_screen_mode: true }, + header: { title: { tag: "plain_text", content: "历史会话" }, template: "blue" }, + elements, + }; +} + +export function buildMoveProjectCard(params: { + readonly organizationId: string; + readonly projectId: string; + readonly projectName: string; + readonly folderId: string | null; + readonly parentFolderId: string | null; + readonly breadcrumb: string; + readonly childFolders: readonly { readonly id: string; readonly name: string }[]; +}): Record { + const navigation: unknown[] = []; + if (params.folderId !== null) { + navigation.push(actionButton("⬆ 上一级", { + action: "browse_move_destination", + organization_id: params.organizationId, + project_id: params.projectId, + ...(params.parentFolderId !== null ? { folder_id: params.parentFolderId } : {}), + })); + } + for (const folder of params.childFolders) { + navigation.push(actionButton(`📁 ${buttonLabel(folder.name, 22)}`, { + action: "browse_move_destination", + organization_id: params.organizationId, + project_id: params.projectId, + folder_id: folder.id, + })); + } + const elements: unknown[] = [ + { tag: "markdown", content: `移动 **${escapeMarkdown(params.projectName)}**\n当前位置: **${escapeMarkdown(params.breadcrumb)}**` }, + ]; + for (let index = 0; index < navigation.length; index += 5) { + elements.push({ tag: "action", actions: navigation.slice(index, index + 5) }); + } + elements.push({ tag: "action", actions: [actionButton("移动到这里", { + action: "move_project", + organization_id: params.organizationId, + project_id: params.projectId, + ...(params.folderId !== null ? { folder_id: params.folderId } : {}), + }, "primary")] }); + return { + config: { wide_screen_mode: true }, + header: { title: { tag: "plain_text", content: "移动项目" }, template: "blue" }, + elements, }; } @@ -120,11 +251,15 @@ export function projectOnboardingActionFromValue(value: unknown): ProjectOnboard const folderId = fields.folder_id; const searchQuery = fields.search_query; const page = fields.page; + const agentRoleId = fields.agent_role_id; + const sessionId = fields.session_id; if (!isAction(rawAction) || typeof organizationId !== "string" || organizationId === "") return null; if ((rawAction === "bind_project" || rawAction === "rename_project") && (typeof projectId !== "string" || projectId === "")) return null; if (rawAction === "search_page" && (typeof searchQuery !== "string" || !validPage(page))) return null; if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) return null; if (page !== undefined && !validPage(page)) return null; + if (rawAction === "select_agent_role" && (typeof agentRoleId !== "string" || agentRoleId === "")) return null; + if (rawAction === "resume_agent_session" && (typeof sessionId !== "string" || sessionId === "")) return null; return { action: rawAction, organization_id: organizationId, @@ -132,6 +267,8 @@ export function projectOnboardingActionFromValue(value: unknown): ProjectOnboard ...(typeof folderId === "string" && folderId !== "" ? { folder_id: folderId } : {}), ...(typeof searchQuery === "string" ? { search_query: searchQuery.slice(0, 100) } : {}), ...(typeof page === "number" ? { page } : {}), + ...(typeof agentRoleId === "string" && agentRoleId !== "" ? { agent_role_id: agentRoleId } : {}), + ...(typeof sessionId === "string" && sessionId !== "" ? { session_id: sessionId } : {}), }; } @@ -258,6 +395,93 @@ function projectPage(view: ProjectOnboardingView): ProjectDiscoveryPage { }; } +function renameProjectForm(params: { + readonly organizationId: string; + readonly projectId: string; + readonly projectName: string; +}): unknown { + return { + tag: "form", + name: "project_rename_form", + fallback: { + tag: "fallback_text", + text: { tag: "plain_text", content: "请升级飞书客户端后修改项目名称。" }, + }, + elements: [ + { + tag: "input", + name: "project_name", + required: true, + max_length: 100, + default_value: params.projectName, + placeholder: { tag: "plain_text", content: "请输入项目名称" }, + }, + { + tag: "button", + name: "project_rename_submit", + text: { tag: "plain_text", content: "保存项目名称" }, + type: "primary", + complex_interaction: true, + action_type: "form_submit", + value: { project_onboarding: { + action: "rename_project", + organization_id: params.organizationId, + project_id: params.projectId, + } }, + }, + ], + }; +} + +function createFolderForm(params: { readonly organizationId: string; readonly projectId: string }): unknown { + return { + tag: "form", + name: "folder_create_form", + elements: [ + { + tag: "input", + name: "folder_name", + required: true, + max_length: 100, + placeholder: { tag: "plain_text", content: "在当前目录下新建 Folder" }, + }, + { + tag: "button", + name: "folder_create_submit", + text: { tag: "plain_text", content: "新建目录" }, + type: "default", + complex_interaction: true, + action_type: "form_submit", + value: { project_onboarding: { + action: "create_folder", + organization_id: params.organizationId, + project_id: params.projectId, + } }, + }, + ], + }; +} + +function sessionSummary(session: { + readonly title: string | null; + readonly updatedAt: Date; + readonly runCount: number; +} | null | undefined): string { + if (session === null || session === undefined) return "当前会话: **尚未开始**"; + return `当前会话: **${escapeMarkdown(session.title ?? "未命名会话")}** · ${session.runCount} runs · ${formatDate(session.updatedAt)}`; +} + +function formatDate(value: Date): string { + return new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(value); +} + function actionButton( label: string, value: ProjectOnboardingActionValue, @@ -273,7 +497,11 @@ function actionButton( function isAction(value: unknown): value is ProjectOnboardingActionValue["action"] { return value === "create_project_from_chat" || value === "bind_project" - || value === "browse_folder" || value === "search_page" || value === "rename_project"; + || value === "browse_folder" || value === "search_page" || value === "rename_project" + || value === "select_agent_role" || value === "new_agent_session" + || value === "show_session_history" || value === "resume_agent_session" + || value === "compact_agent_session" || value === "browse_move_destination" + || value === "move_project" || value === "create_folder"; } function validPage(value: unknown): value is number { diff --git a/hub/src/feishu/slashCommands.ts b/hub/src/feishu/slashCommands.ts index 1ac35e0..2753b10 100644 --- a/hub/src/feishu/slashCommands.ts +++ b/hub/src/feishu/slashCommands.ts @@ -1,11 +1,6 @@ import type { PrismaClient } from "@prisma/client"; -import type { FastifyBaseLogger } from "fastify"; import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js"; -import type { ModelRegistry, RoleEntry } from "../agent/models.js"; -import type { RuntimeSettings } from "../settings/runtime.js"; -import { lockActiveProjectOrganization } from "../org/status.js"; import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js"; -import type { TriggerQueue } from "./triggerQueue.js"; export interface SlashInvocation { readonly name: string; @@ -21,20 +16,13 @@ export interface SlashCommandRunContext { } export interface SlashCommandDefinition { - readonly name: string; + readonly name: "help" | "project" | "usage"; readonly usage: string; readonly summary: string; readonly details: readonly string[]; run(context: SlashCommandRunContext): Promise; } -export interface SlashCommandRegistryDeps { - readonly prisma: PrismaClient; - readonly settings: RuntimeSettings; - readonly logger: FastifyBaseLogger; - readonly triggerQueue: TriggerQueue; -} - const TERMINAL_RUN_STATUSES = ["COMPLETED", "FAILED", "TIMED_OUT", "CANCELED"] as const; export function parseSlashInvocation(prompt: string): SlashInvocation | null { @@ -43,179 +31,94 @@ export function parseSlashInvocation(prompt: string): SlashInvocation | null { const tokens = trimmed.split(/\s+/); const rawCommand = tokens[0]; if (rawCommand === undefined || rawCommand.length <= 1) return null; - return { - name: rawCommand.slice(1), - args: tokens.slice(1), - }; + return { name: rawCommand.slice(1), args: tokens.slice(1) }; } -export function parseSlashHelpSubcommand(invocation: SlashInvocation): string | null { - if (invocation.name === "help") return null; - return invocation.args.length === 1 && invocation.args[0] === "help" - ? invocation.name - : null; -} - -export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): ReadonlyMap { +export function createSlashCommandRegistry( + deps: { readonly prisma: PrismaClient }, +): ReadonlyMap { const commands = new Map(); const add = (command: SlashCommandDefinition): void => { - if (commands.has(command.name)) { - throw new Error(`duplicate slash command definition: /${command.name}`); - } + if (commands.has(command.name)) throw new Error(`duplicate slash command definition: /${command.name}`); commands.set(command.name, command); }; add({ name: "help", - usage: "/help [command]", - summary: "查看可用 slash 命令或单个命令说明。", - details: [ - "不创建 agent run,也不改变当前会话。", - "支持 /help new 和 /new help 两种写法。", - ], - run: async ({ invocation, projectId, chatId, rt, sendOptions }) => { - const registry = await deps.settings.modelRegistry({ projectId }); - await sendText(rt, chatId, formatHelpCommandInvocation(invocation, registry, commands), sendOptions); - }, - }); - - add({ - name: "new", - usage: "/new", - summary: "开新会话,下次 @bot 将从头开始。", - details: [ - "归档当前未归档的 agent session。", - "不会清空当前项目的等待队列。", - ], - run: async ({ projectId, chatId, rt, sendOptions }) => { - await deps.prisma.$transaction(async (tx) => { - await lockActiveProjectOrganization(tx, projectId); - await tx.agentSession.updateMany({ - where: { projectId, archivedAt: null }, - data: { archivedAt: new Date() }, - }); - }); - await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。", sendOptions); + usage: "/help [project|usage]", + summary: "查看 Hub 命令。", + details: ["未知 slash 命令会明确报错,不会发送给 Agent。"], + run: async ({ invocation, chatId, rt, sendOptions }) => { + if (invocation.args.length > 1) { + await sendText(rt, chatId, "用法: /help [project|usage]", sendOptions); + return; + } + const target = invocation.args[0]; + await sendText(rt, chatId, target === undefined + ? formatSlashOverview(commands) + : formatSlashHelpTarget(target, commands), sendOptions); }, }); add({ name: "project", usage: "/project", - summary: "打开当前项目管理卡片,可修改项目名称。", - details: [ - "要求操作者拥有当前项目的 MANAGE 权限。", - "实际卡片由飞书 trigger adapter 渲染,不创建 agent run。", - ], + summary: "打开当前项目控制台,切换角色、管理会话和项目。", + details: ["不同区域按当前操作者的项目与组织权限显示。"], run: async ({ chatId, rt, sendOptions }) => { - await sendText(rt, chatId, "请在飞书项目群中使用 @bot /project 打开项目管理卡片。", sendOptions); + await sendText(rt, chatId, "请在绑定的项目群中使用 /project。", sendOptions); }, }); add({ - name: "resume", - usage: "/resume", - summary: "恢复最近一次已归档的会话。", - details: [ - "只恢复当前项目最近归档的 agent session。", - "没有可恢复会话时只回复提示,不会创建 agent run。", - ], - run: async ({ projectId, chatId, rt, sendOptions }) => { - const resumed = await deps.prisma.$transaction(async (tx) => { - await lockActiveProjectOrganization(tx, projectId); - const latest = await tx.agentSession.findFirst({ - where: { projectId, archivedAt: { not: null } }, - orderBy: { archivedAt: "desc" }, - select: { id: true }, - }); - if (latest === null) return false; - await tx.agentSession.update({ - where: { id: latest.id }, - data: { archivedAt: null }, - }); - return true; - }); - if (!resumed) { - await sendText(rt, chatId, "没有可恢复的会话。", sendOptions); - return; - } - await sendText(rt, chatId, "已恢复上一个会话。", sendOptions); - }, - }); - - add({ - name: "cost", - usage: "/cost", - summary: "查看当前会话已记录的 agent 成本。", - details: [ - "只读取当前项目未归档 agent session 下已经结束的 run,不创建 agent run。", - "只统计运行时真实记录到 AgentRun.costUsd 的成本;未记录成本的 run 会单独列出。", - ], + name: "usage", + usage: "/usage [current|project]", + summary: "查看 Hub 记录的真实 Agent 用量与成本。", + details: ["current 只统计当前角色的活跃会话;project 统计整个项目。"], run: async ({ invocation, projectId, chatId, rt, sendOptions }) => { - if (invocation.args.length > 0) { - await sendText(rt, chatId, ["用法错误: /cost 暂不接受参数。", "", formatBuiltinSlashCommandHelp(commands.get("cost")!)].join("\n"), sendOptions); + const scope = invocation.args[0] ?? "current"; + if (invocation.args.length > 1 || (scope !== "current" && scope !== "project")) { + await sendText(rt, chatId, "用法: /usage [current|project]", sendOptions); return; } - - const sessions = await deps.prisma.agentSession.findMany({ - where: { projectId, archivedAt: null }, - orderBy: { updatedAt: "asc" }, - select: { id: true }, - }); - if (sessions.length === 0) { - await sendText(rt, chatId, "当前会话还没有 agent session。", sendOptions); - return; - } - + const sessionIds = scope === "project" + ? undefined + : await currentRoleSessionIds(deps.prisma, projectId, chatId); const runs = await deps.prisma.agentRun.findMany({ where: { projectId, - sessionId: { in: sessions.map((session) => session.id) }, + ...(sessionIds === undefined ? {} : { sessionId: { in: sessionIds } }), status: { in: [...TERMINAL_RUN_STATUSES] }, finishedAt: { not: null }, }, orderBy: { finishedAt: "asc" }, - select: { - model: true, - provider: true, - inputTokens: true, - outputTokens: true, - costUsd: true, - }, + select: { model: true, provider: true, inputTokens: true, outputTokens: true, costUsd: true }, }); - - await sendText(rt, chatId, formatCostReport(runs), sendOptions); - }, - }); - - add({ - name: "reset", - usage: "/reset", - summary: "重置当前会话并清空等待队列。", - details: [ - "归档当前未归档的 agent session。", - "清空当前项目已经排队、尚未开始的触发请求。", - ], - run: async ({ projectId, chatId, rt, sendOptions }) => { - await deps.prisma.$transaction(async (tx) => { - await lockActiveProjectOrganization(tx, projectId); - await tx.agentSession.updateMany({ - where: { projectId, archivedAt: null }, - data: { archivedAt: new Date() }, - }); - }); - const cleared = deps.triggerQueue.clear(projectId); - if (cleared > 0) { - deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset"); - } - await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。", sendOptions); + await sendText(rt, chatId, formatUsageReport(runs, scope), sendOptions); }, }); return commands; } -interface CostReportRun { +async function currentRoleSessionIds( + prisma: PrismaClient, + projectId: string, + chatId: string, +): Promise { + const binding = await prisma.projectGroupBinding.findFirst({ + where: { projectId, chatId, archivedAt: null }, + select: { selectedRole: { select: { roleId: true } } }, + }); + if (binding === null) throw new Error("active project-group binding not found"); + const sessions = await prisma.agentSession.findMany({ + where: { projectId, roleId: binding.selectedRole.roleId, archivedAt: null }, + select: { id: true }, + }); + return sessions.map((session) => session.id); +} + +interface UsageRun { readonly model: string; readonly provider: string; readonly inputTokens: number | null; @@ -223,225 +126,66 @@ interface CostReportRun { readonly costUsd: unknown; } -interface CostReportBucket { - readonly provider: string; - readonly model: string; - runs: number; - inputTokens: number; - outputTokens: number; - costUsd: number; -} - -function formatCostReport(runs: readonly CostReportRun[]): string { - if (runs.length === 0) { - return "当前会话还没有已结束的 agent run。"; - } - - const buckets = new Map(); +function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "project"): string { + if (runs.length === 0) return `${scope === "current" ? "当前角色会话" : "当前项目"}还没有已结束的 Agent run。`; + const buckets = new Map(); let recordedRuns = 0; let unrecordedRuns = 0; let totalInputTokens = 0; let totalOutputTokens = 0; let totalCostUsd = 0; - for (const run of runs) { const costUsd = decimalToNumberOrNull(run.costUsd); - if (costUsd === null) { - unrecordedRuns++; - continue; - } - + if (costUsd === null) { unrecordedRuns++; continue; } recordedRuns++; - const inputTokens = run.inputTokens ?? 0; - const outputTokens = run.outputTokens ?? 0; - totalInputTokens += inputTokens; - totalOutputTokens += outputTokens; - totalCostUsd += costUsd; - const key = `${run.provider}\u0000${run.model}`; - let bucket = buckets.get(key); - if (bucket === undefined) { - bucket = { - provider: run.provider, - model: run.model, - runs: 0, - inputTokens: 0, - outputTokens: 0, - costUsd: 0, - }; - buckets.set(key, bucket); - } + const bucket = buckets.get(key) ?? { + provider: run.provider, model: run.model, runs: 0, inputTokens: 0, outputTokens: 0, costUsd: 0, + }; bucket.runs++; - bucket.inputTokens += inputTokens; - bucket.outputTokens += outputTokens; + bucket.inputTokens += run.inputTokens ?? 0; + bucket.outputTokens += run.outputTokens ?? 0; bucket.costUsd += costUsd; + buckets.set(key, bucket); + totalInputTokens += run.inputTokens ?? 0; + totalOutputTokens += run.outputTokens ?? 0; + totalCostUsd += costUsd; } - - if (recordedRuns === 0) { - return [ - "当前会话已有已结束 agent run,但还没有任何 run 记录到真实成本。", - `未记录成本: ${formatInteger(unrecordedRuns)} runs。`, - "后续 run 需要 SDK 返回 total_cost_usd 才会进入 /cost 合计。", - ].join("\n"); - } - const lines = [ - "当前会话已记录 agent 成本:", - `总计: ${formatUsd(totalCostUsd)}`, - `Runs: ${formatInteger(recordedRuns)} 已记录${unrecordedRuns > 0 ? ` / ${formatInteger(unrecordedRuns)} 未记录` : ""}`, + `${scope === "current" ? "当前角色会话" : "当前项目"}用量`, + `已记录成本: ${formatInteger(recordedRuns)} runs · ${formatUsd(totalCostUsd)}`, `Tokens: input ${formatInteger(totalInputTokens)} / output ${formatInteger(totalOutputTokens)}`, - "", - "按模型:", ]; - - const sortedBuckets = [...buckets.values()].sort((a, b) => b.costUsd - a.costUsd); - for (const bucket of sortedBuckets) { - lines.push( - `- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}, input ${formatInteger(bucket.inputTokens)} / output ${formatInteger(bucket.outputTokens)}`, - ); + if (unrecordedRuns > 0) lines.push(`另有 ${formatInteger(unrecordedRuns)} 个 run 未记录成本。`); + for (const bucket of buckets.values()) { + lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}`); } - return lines.join("\n"); } -function formatHelpCommandInvocation( - invocation: SlashInvocation, - registry: ModelRegistry, - slashCommands: ReadonlyMap, -): string { - if (invocation.args.length > 1) { - const helpCommand = slashCommands.get("help"); - if (helpCommand === undefined) { - throw new Error("slash command registry is missing /help"); - } - return [ - "用法错误: /help 只接受一个命令名。", - "", - formatBuiltinSlashCommandHelp(helpCommand), - ].join("\n"); - } - - const target = invocation.args[0]; - if (target === undefined) return formatSlashOverview(registry, slashCommands); - const normalizedTarget = normalizeHelpTarget(target); - if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands); - return formatSlashHelpTarget(normalizedTarget, registry, slashCommands); -} - export function formatSlashHelpTarget( target: string, - registry: ModelRegistry, - slashCommands: ReadonlyMap, + commands: ReadonlyMap, ): string { - const normalizedTarget = normalizeHelpTarget(target); - if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands); - return formatSlashCommandHelp(normalizedTarget, registry, slashCommands) ?? formatUnknownSlashHelp(normalizedTarget, registry, slashCommands); -} - -function normalizeHelpTarget(target: string): string { - return target.trim().replace(/^\/+/, ""); -} - -function formatSlashOverview( - registry: ModelRegistry, - slashCommands: ReadonlyMap, -): string { - const lines = [ - "可用 slash 命令:", - ...[...slashCommands.values()].map((command) => `/${command.name} - ${command.summary}`), - ]; - const roles = visibleRoleCommands(registry, slashCommands); - if (roles.length > 0) { - lines.push("", "角色命令:"); - for (const role of roles) { - lines.push(`/${role.id} <需求> - 使用“${role.label}”角色发起请求。`); - } - } else { - lines.push("", "当前没有配置角色命令。"); - } - lines.push("", "查看单个命令: /help new 或 /new help。"); - return lines.join("\n"); -} - -function formatSlashCommandHelp( - commandName: string, - registry: ModelRegistry, - slashCommands: ReadonlyMap, -): string | null { - const normalizedName = normalizeHelpTarget(commandName); - const slashCommand = slashCommands.get(normalizedName); - if (slashCommand !== undefined) return formatBuiltinSlashCommandHelp(slashCommand); - - const role = registry.role(normalizedName); - if (role === undefined) return null; - if (slashCommands.has(role.id)) return null; - return formatRoleSlashCommandHelp(role); -} - -function formatBuiltinSlashCommandHelp(command: SlashCommandDefinition): string { - const helpUsage = command.name === "help" - ? "帮助: /help help" - : `帮助: /help ${command.name} 或 /${command.name} help`; + const normalized = target.trim().replace(/^\/+/, ""); + const command = commands.get(normalized); + if (command === undefined) return [`未知 slash 命令 /${normalized}。`, "", formatSlashOverview(commands)].join("\n"); return [ `/${command.name}`, command.summary, "", `用法: ${command.usage}`, ...command.details.map((detail) => `- ${detail}`), - "", - helpUsage, ].join("\n"); } -function formatRoleSlashCommandHelp(role: RoleEntry): string { - const lines = [ - `/${role.id}`, - `使用“${role.label}”角色发起一次 agent run。`, - "", - `用法: /${role.id} <需求>`, - "- 真正运行时仍会按当前项目的 role.trigger 授权检查。", - ]; - if (role.defaultModel !== undefined) { - lines.push(`- 默认模型: ${role.defaultModel}`); - } - lines.push( - `- 工具范围: ${roleToolsDescription(role)}`, - `- Skills: ${roleSkillsDescription(role)}`, - "", - `帮助: /help ${role.id} 或 /${role.id} help`, - ); - return lines.join("\n"); -} - -function roleSkillsDescription(role: RoleEntry): string { - if (role.skills === undefined || role.skills.length === 0) return "无"; - return role.skills.map((skill) => `${skill.name}@${skill.version}`).join(", "); -} - -function roleToolsDescription(role: RoleEntry): string { - if (role.tools === undefined) return "全部已注册工具"; - if (role.tools.length === 0) return "无"; - return role.tools.join(", "); -} - -function formatUnknownSlashHelp( - commandName: string, - registry: ModelRegistry, - slashCommands: ReadonlyMap, -): string { - const normalizedName = normalizeHelpTarget(commandName); +function formatSlashOverview(commands: ReadonlyMap): string { return [ - `未知 slash 命令 /${normalizedName}。`, + "可用 slash 命令:", + ...[...commands.values()].map((command) => `/${command.name} - ${command.summary}`), "", - formatSlashOverview(registry, slashCommands), + "普通消息会使用 /project 中选定的当前角色。", ].join("\n"); } - -function visibleRoleCommands( - registry: ModelRegistry, - slashCommands: ReadonlyMap, -): readonly RoleEntry[] { - return registry - .listRoles() - .filter((role) => !slashCommands.has(role.id)); -} diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index df96567..990e8df 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -52,22 +52,34 @@ import { type StagedMessageResourceBatch, } from "./resourceStaging.js"; import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js"; -import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js"; +import { createSlashCommandRegistry, parseSlashInvocation } from "./slashCommands.js"; import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js"; import { bindFeishuChatToProject, + createFolder, createProjectFromFeishuChat, ensureOrganizationProjectSettings, + moveProjectToFolder, renameProjectForActor, } from "../projectOnboarding.js"; import { buildProjectManagementCard, + buildMoveProjectCard, + buildSessionHistoryCard, buildProjectOnboardingResolvedCard, buildUnboundChatOnboardingCard, projectOnboardingActionFromValue, type ProjectOnboardingActionValue, type ProjectOnboardingView, } from "./projectOnboardingCard.js"; +import { + archiveCurrentRoleSession, + browseFolderDestinations, + listRoleSessionHistory, + loadProjectConsole, + resumeRoleSession, + selectProjectGroupRole, +} from "./projectConsole.js"; import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js"; import { browseBindableFolder, discoverBindableProjects } from "../projectDiscovery.js"; @@ -113,6 +125,7 @@ interface TriggerRunContext { readonly projectId: string; readonly senderOpenId: string; readonly actor: TriggerActor; + readonly roleId: string; } type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped"; @@ -150,9 +163,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { : new SiloFixedWindowRateLimiter(deps.maxFeishuEventsPerMinute, 60_000); const slashCommands = createSlashCommandRegistry({ prisma: deps.prisma, - settings: deps.settings, - logger: deps.logger, - triggerQueue, }); const batchContexts = new Map(); // runId → AbortController for live runs. Registered when a run starts, @@ -178,10 +188,48 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { } }, deps.messageBatcherOptions); + async function projectConsoleCard( + projectId: string, + chatId: string, + actorOpenId: string, + title?: string, + ): Promise> { + const state = await loadProjectConsole(deps.prisma, { projectId, chatId }); + const actor = { feishuOpenId: actorOpenId, chatId }; + const [manageDecision, organization, roleDecisions] = await Promise.all([ + authorizer.can({ actor, action: "collaborator.manage", resource: { type: "PROJECT", id: projectId } }), + resolveSingleActiveOrganizationForFeishuUser(actorOpenId), + Promise.all(state.roles.map(async (role) => ({ + role, + decision: await authorizer.can({ + actor, + action: "role.trigger", + resource: { type: "PROJECT", id: projectId }, + roleId: role.roleId, + }), + }))), + ]); + const visibleRoles = roleDecisions.filter(({ decision }) => decision.allowed).map(({ role }) => role); + const isOrgAdmin = organization.status === "ok" && + organization.organizationId === state.organizationId && isOrgAdminRole(organization.role); + return buildProjectManagementCard({ + organizationId: state.organizationId, + projectId: state.projectId, + projectName: state.projectName, + breadcrumb: state.breadcrumb, + selectedRole: state.selectedRole, + roles: visibleRoles, + currentSession: state.currentSession, + canManageProject: manageDecision.allowed || isOrgAdmin, + canCreateFolder: isOrgAdmin, + ...(title !== undefined ? { title } : {}), + }); + } + async function startAgentRun( context: TriggerRunContext, cleanPrompt: string, - options: { readonly queueIfLocked?: boolean } = {}, + options: { readonly queueIfLocked?: boolean; readonly nativeSlash?: boolean } = {}, ): Promise { const { msg, rt, chatId, projectId, senderOpenId, actor } = context; const sendOptions = sendOptionsForTriggerMessage(msg); @@ -195,7 +243,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { return "rejected"; } if (!queueIfLocked) return "locked"; - return enqueueLockedTrigger(context, cleanPrompt); + return enqueueLockedTrigger(context, cleanPrompt, options.nativeSlash === true ? "native_compact" : "conversation"); } const project = await deps.prisma.project.findUnique({ @@ -207,15 +255,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { return "skipped"; } - // ADR-0017: role-as-data. Parse a leading `/` command; unknown - // slashes are left as literal text (extractRole returns null). Falls back - // to "draft" when no role is named — the registry degrades gracefully. const models = await deps.settings.modelRegistry({ projectId }); - const { roleId: parsedRole, prompt: parsedAgentPrompt } = extractRole(cleanPrompt, models); - const roleId = parsedRole ?? "draft"; + const roleId = context.roleId; // ADR-0019: role trigger is the second gate after project agent.trigger. - // Unconfigured roles remain open for back-compat; configured roles require - // a matching active RoleTriggerGrant for any resolved principal. + // Configured roles require a matching active RoleTriggerGrant for any + // resolved principal; otherwise the role remains open within the project. const roleDecision = await authorizer.can({ actor, action: "role.trigger", @@ -262,8 +306,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { projectWorkspaceRoot, deps.resourceLimits, ); - const agentPrompt = appendStagedResourcePaths(parsedAgentPrompt, stagedResources, project.workspaceDir); - const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext); + const agentPrompt = appendStagedResourcePaths(cleanPrompt, stagedResources, project.workspaceDir); + const promptForAgent = options.nativeSlash === true + ? cleanPrompt + : withFeishuTriggerContext(agentPrompt, feishuTriggerContext); // Linearization point for org lifecycle + session/run/lock admission. // FOR SHARE on the Organization row conflicts with a concurrent status @@ -623,10 +669,16 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { return "started"; } - async function enqueueLockedTrigger(context: TriggerRunContext, cleanPrompt: string): Promise { + async function enqueueLockedTrigger( + context: TriggerRunContext, + cleanPrompt: string, + executionKind: QueuedTrigger["executionKind"] = "conversation", + ): Promise { const position = triggerQueue.enqueue(context.projectId, { chatId: context.chatId, prompt: cleanPrompt, + roleId: context.roleId, + executionKind, msg: context.msg, senderOpenId: context.senderOpenId, actor: context.actor, @@ -664,7 +716,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { const next = triggerQueue.dequeue(projectId); if (next === null) return; - const outcome = await startAgentRun(contextFromQueuedTrigger(next, rt), next.prompt, { queueIfLocked: false }); + const outcome = await startAgentRun(contextFromQueuedTrigger(next, rt), next.prompt, { + queueIfLocked: false, + nativeSlash: next.executionKind === "native_compact", + }); if (outcome === "started") return; if (outcome === "locked") { requeueTrigger(next); @@ -681,6 +736,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { projectId: trigger.projectId, senderOpenId: trigger.senderOpenId, actor: trigger.actor, + roleId: trigger.roleId, }; } @@ -688,6 +744,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { const position = triggerQueue.enqueue(trigger.projectId, { chatId: trigger.chatId, prompt: trigger.prompt, + roleId: trigger.roleId, + executionKind: trigger.executionKind, msg: trigger.msg, senderOpenId: trigger.senderOpenId, actor: trigger.actor, @@ -761,6 +819,169 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { if (organization.organizationId !== action.organization_id) { throw new Error("project onboarding organization mismatch"); } + if ( + action.action === "select_agent_role" || + action.action === "new_agent_session" || + action.action === "show_session_history" || + action.action === "resume_agent_session" || + action.action === "compact_agent_session" + ) { + const projectId = action.project_id; + if (projectId === undefined) throw new Error(`${action.action} requires project_id`); + const binding = await deps.prisma.projectGroupBinding.findFirst({ + where: { projectId, chatId, archivedAt: null }, + select: { id: true }, + }); + if (binding === null) throw new Error("project console action requires the project to be bound to this chat"); + const state = await loadProjectConsole(deps.prisma, { projectId, chatId }); + const targetRole = action.action === "select_agent_role" + ? state.roles.find((role) => role.id === action.agent_role_id) + : state.selectedRole; + if (targetRole === undefined) throw new Error("selected Agent role is unavailable"); + const actor = { feishuOpenId: operatorOpenId, chatId }; + const [triggerDecision, roleDecision] = await Promise.all([ + authorizer.can({ actor, action: "agent.trigger", resource: { type: "PROJECT", id: projectId } }), + authorizer.can({ + actor, + action: "role.trigger", + resource: { type: "PROJECT", id: projectId }, + roleId: targetRole.roleId, + }), + ]); + if (!triggerDecision.allowed || !roleDecision.allowed || roleDecision.actorUserId === undefined) { + throw new Error(`not authorized for Agent role ${targetRole.roleId}`); + } + + if (action.action === "select_agent_role") { + await selectProjectGroupRole(deps.prisma, { + projectId, + chatId, + agentRoleId: targetRole.id, + actorUserId: roleDecision.actorUserId, + }); + if (messageId === undefined) throw new Error("role selection requires message id"); + await patchCard(rt, messageId, await projectConsoleCard( + projectId, + chatId, + operatorOpenId, + `已切换至 ${targetRole.label}`, + )); + return; + } + if (action.action === "new_agent_session") { + await archiveCurrentRoleSession(deps.prisma, { + projectId, + chatId, + actorUserId: roleDecision.actorUserId, + }); + if (messageId === undefined) throw new Error("new session requires message id"); + await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "已新开会话")); + return; + } + if (action.action === "show_session_history") { + const sessions = await listRoleSessionHistory(deps.prisma, { projectId, chatId }); + if (messageId === undefined) throw new Error("session history requires message id"); + await patchCard(rt, messageId, buildSessionHistoryCard({ + organizationId: state.organizationId, + projectId, + roleLabel: state.selectedRole.label, + sessions, + })); + return; + } + if (action.action === "resume_agent_session") { + if (action.session_id === undefined) throw new Error("resume_agent_session requires session_id"); + await resumeRoleSession(deps.prisma, { + projectId, + chatId, + sessionId: action.session_id, + actorUserId: roleDecision.actorUserId, + }); + if (messageId === undefined) throw new Error("session resume requires message id"); + await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "已恢复历史会话")); + return; + } + if (state.currentSession === null || !state.currentSession.sdkSessionReady) { + throw new Error("当前角色还没有可压缩的 Claude 会话"); + } + const commandMessage: MessageReceiveEvent["message"] = { + message_id: messageId ?? randomUUID(), + chat_id: chatId, + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "/compact" }), + mentions: [], + }; + await startAgentRun({ + msg: commandMessage, + rt, + chatId, + projectId, + senderOpenId: operatorOpenId, + actor, + roleId: state.selectedRole.roleId, + }, "/compact", { nativeSlash: true }); + return; + } + if ( + action.action === "browse_move_destination" || + action.action === "move_project" || + action.action === "create_folder" + ) { + const projectId = action.project_id; + if (projectId === undefined) throw new Error(`${action.action} requires project_id`); + const state = await loadProjectConsole(deps.prisma, { projectId, chatId }); + const isOrgAdmin = isOrgAdminRole(organization.role); + const manageDecision = await authorizer.can({ + actor: { feishuOpenId: operatorOpenId, chatId }, + action: "collaborator.manage", + resource: { type: "PROJECT", id: projectId }, + }); + if (!isOrgAdmin && !manageDecision.allowed) throw new Error("project MANAGE permission is required"); + + if (action.action === "browse_move_destination") { + const page = await browseFolderDestinations(deps.prisma, { + organizationId: state.organizationId, + folderId: action.folder_id ?? null, + }); + if (messageId === undefined) throw new Error("folder navigation requires message id"); + await patchCard(rt, messageId, buildMoveProjectCard({ + organizationId: state.organizationId, + projectId, + projectName: state.projectName, + ...page, + })); + return; + } + if (action.action === "move_project") { + await moveProjectToFolder(deps.prisma, { projectId, folderId: action.folder_id ?? null }); + await writeAudit(deps.prisma, { + projectId, + ...(manageDecision.actorUserId !== undefined ? { actorUserId: manageDecision.actorUserId } : {}), + action: "project.moved_from_feishu", + metadata: { folderId: action.folder_id ?? null }, + }); + if (messageId === undefined) throw new Error("project move requires message id"); + await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "项目已移动")); + return; + } + if (!isOrgAdmin) throw new Error("creating an Organization folder requires OWNER or ADMIN"); + const folderName = event.action.form_value?.["folder_name"]; + if (typeof folderName !== "string") throw new Error("create folder form is missing folder_name"); + const folder = await createFolder(deps.prisma, { + organizationId: state.organizationId, + name: folderName.slice(0, 100), + ...(state.folderId !== null ? { parentId: state.folderId } : {}), + }); + await writeAudit(deps.prisma, { + projectId, + action: "folder.created_from_feishu", + metadata: { folderId: folder.id, parentId: state.folderId, name: folder.name }, + }); + if (messageId === undefined) throw new Error("folder creation requires message id"); + await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "目录已创建")); + return; + } if (action.action === "rename_project") { const projectId = action.project_id; if (projectId === undefined) throw new Error("rename_project action requires project_id"); @@ -778,12 +999,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { name: submittedName.slice(0, 100), }); if (messageId === undefined) throw new Error("project rename requires message id"); - await patchCard(rt, messageId, buildProjectManagementCard({ - organizationId: organization.organizationId, - projectId: renamed.projectId, - projectName: renamed.name, - title: "项目名称已更新", - })); + await patchCard(rt, messageId, await projectConsoleCard( + renamed.projectId, + chatId, + operatorOpenId, + "项目名称已更新", + )); deps.logger.info({ chatId, projectId, operatorOpenId }, "project onboarding: project renamed"); return; } @@ -834,12 +1055,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { folderId: action.folder_id, }); if (messageId !== undefined) { - await patchCard(rt, messageId, buildProjectManagementCard({ - organizationId: organization.organizationId, - projectId: project.projectId, - projectName: name, - title: "已创建并绑定项目", - })); + await patchCard(rt, messageId, await projectConsoleCard( + project.projectId, + chatId, + operatorOpenId, + "已创建并绑定项目", + )); } deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: created project from chat"); return; @@ -863,16 +1084,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { chatId, }); if (messageId !== undefined) { - const boundProject = await deps.prisma.project.findUniqueOrThrow({ - where: { id: project.projectId }, - select: { name: true }, - }); - await patchCard(rt, messageId, buildProjectManagementCard({ - organizationId: organization.organizationId, - projectId: project.projectId, - projectName: boundProject.name, - title: "已绑定项目", - })); + await patchCard(rt, messageId, await projectConsoleCard( + project.projectId, + chatId, + operatorOpenId, + "已绑定项目", + )); } deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: bound existing project"); } catch (e) { @@ -905,7 +1122,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { } const binding = await deps.prisma.projectGroupBinding.findFirst({ where: { chatId, archivedAt: null }, - select: { projectId: true }, + select: { projectId: true, selectedRole: { select: { roleId: true } } }, }); if (binding === null) { deps.logger.debug({ chatId }, "feishu interrupt: chat not bound to any project"); @@ -1010,7 +1227,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { // ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group. const binding = await deps.prisma.projectGroupBinding.findFirst({ where: { chatId, archivedAt: null }, - select: { projectId: true }, + select: { projectId: true, selectedRole: { select: { roleId: true } } }, }); if (binding === null) { deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project"); @@ -1082,53 +1299,33 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { } } if (cleanPrompt === null) return; - const runContext: TriggerRunContext = { msg, rt, chatId, projectId, senderOpenId, actor }; + const runContext: TriggerRunContext = { + msg, + rt, + chatId, + projectId, + senderOpenId, + actor, + roleId: binding.selectedRole.roleId, + }; - // Slash commands: session management, not agent runs. These bypass the - // batcher. Session commands bypass the lock because they don't create runs; - // role/unknown slash prompts still use the normal run path and queue if locked. + // Hub owns a closed slash-command protocol. Unknown commands fail visibly; + // they are never downgraded to Agent text or forwarded to the SDK. if (cleanPrompt.startsWith("/")) { const invocation = parseSlashInvocation(cleanPrompt); - const helpSubcommandTarget = invocation === null ? null : parseSlashHelpSubcommand(invocation); - if (helpSubcommandTarget !== null) { - // Help is generated on demand because role/tool configuration is runtime data. - const models = await deps.settings.modelRegistry({ projectId }); - await sendText(rt, chatId, formatSlashHelpTarget(helpSubcommandTarget, models, slashCommands), sendOptionsForTriggerMessage(msg)); - return; - } - if (invocation !== null) { if (invocation.name === "project") { if (invocation.args.length > 0) { - await sendText(rt, chatId, "用法: /project(打开当前项目管理卡片)", sendOptionsForTriggerMessage(msg)); + await sendText(rt, chatId, "用法: /project", sendOptionsForTriggerMessage(msg)); return; } - const project = await deps.prisma.project.findUniqueOrThrow({ - where: { id: projectId }, - select: { id: true, name: true, organizationId: true }, - }); - const organization = await resolveSingleActiveOrganizationForFeishuUser(senderOpenId); - if (organization.status !== "ok" || organization.organizationId !== project.organizationId) { - await sendText(rt, chatId, "当前身份不属于该项目组织。", sendOptionsForTriggerMessage(msg)); - return; - } - if (!isOrgAdminRole(organization.role)) { - const decision = await authorizer.can({ - actor, - action: "collaborator.manage", - resource: { type: "PROJECT", id: projectId }, - }); - if (!decision.allowed) { - await sendText(rt, chatId, "你没有管理当前项目的权限。", sendOptionsForTriggerMessage(msg)); - return; - } - } - await sendCard(rt, chatId, buildProjectManagementCard({ - organizationId: project.organizationId, - projectId: project.id, - projectName: project.name, - }), sendOptionsForTriggerMessage(msg)); - deps.logger.info({ chatId, projectId, senderOpenId }, "feishu project management card opened"); + await sendCard( + rt, + chatId, + await projectConsoleCard(projectId, chatId, senderOpenId), + sendOptionsForTriggerMessage(msg), + ); + deps.logger.info({ chatId, projectId, senderOpenId }, "feishu project console opened"); return; } const slashCommand = slashCommands.get(invocation.name); @@ -1146,8 +1343,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { return; } } - - await startAgentRun(runContext, cleanPrompt); + const name = invocation?.name ?? cleanPrompt.slice(1).trim(); + await sendText(rt, chatId, `未知 slash 命令 /${name}。使用 /help 查看可用命令。`, sendOptionsForTriggerMessage(msg)); return; } @@ -1161,11 +1358,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { await enqueueLockedTrigger(runContext, cleanPrompt); return; } - const key = messageBatchKey(chatId, senderOpenId); + const key = messageBatchKey(chatId, senderOpenId, runContext.roleId); if (!batchContexts.has(key)) { batchContexts.set(key, runContext); } - await messageBatcher.enqueue(chatId, senderOpenId, cleanPrompt); + await messageBatcher.enqueue(chatId, senderOpenId, cleanPrompt, runContext.roleId); return; } @@ -1750,27 +1947,3 @@ async function compensateFailedResourceAdmission( ); } } - -/** - * Parse a leading `/` command from a prompt (e.g. `/draft 帮我写教案`). - * Returns the role id and the remaining prompt with the command stripped. - * Control/help commands already handled upstream are ignored here — they never - * reach this function. - * - * Unknown `/foo` that isn't a registered role: returns `null` role and the - * original prompt unchanged (the slash is treated as literal text). Role - * resolution still happens via the registry's fallback. - */ -export function extractRole( - prompt: string, - registry: { role(id: string): unknown }, -): { roleId: string | null; prompt: string } { - const m = /^\/(\S+)\s*/.exec(prompt); - if (m === null || m[1] === undefined) return { roleId: null, prompt }; - const roleId = m[1]; - if (registry.role(roleId) === undefined) { - // Unknown slash command — leave the prompt as-is (no silent role switch). - return { roleId: null, prompt }; - } - return { roleId, prompt: prompt.slice(m[0].length) }; -} diff --git a/hub/src/feishu/triggerQueue.ts b/hub/src/feishu/triggerQueue.ts index ae86f8c..f1475e3 100644 --- a/hub/src/feishu/triggerQueue.ts +++ b/hub/src/feishu/triggerQueue.ts @@ -4,6 +4,9 @@ export interface QueuedTrigger { readonly projectId: string; readonly chatId: string; readonly prompt: string; + /** Role frozen when the message was accepted; later group switches cannot change it. */ + readonly roleId: string; + readonly executionKind: "conversation" | "native_compact"; readonly msg: MessageReceiveEvent["message"]; readonly senderOpenId: string; readonly actor: { readonly feishuOpenId: string; readonly chatId: string }; diff --git a/hub/src/projectOnboarding.ts b/hub/src/projectOnboarding.ts index 517eb59..f9d31f6 100644 --- a/hub/src/projectOnboarding.ts +++ b/hub/src/projectOnboarding.ts @@ -165,6 +165,7 @@ export async function renameProjectForActor( }); await tx.auditEntry.create({ data: { + organizationId: project.organizationId, projectId: project.id, actorUserId: actor.userId, action: "project.renamed", @@ -243,6 +244,7 @@ export async function bindFeishuChatToProject( const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId); await prisma.$transaction(async (tx) => { await requireActiveOrganizationTx(tx, project.organizationId); + const defaultRole = await requireDefaultAgentRole(tx, project.organizationId); const activeProjectBinding = await tx.projectGroupBinding.findFirst({ where: { projectId: project.id, archivedAt: null }, select: { chatId: true }, @@ -258,7 +260,13 @@ export async function bindFeishuChatToProject( throw new Error(`Feishu chat ${chatId} is already bound to project ${activeChatBinding.projectId}`); } await tx.projectGroupBinding.create({ - data: { projectId: project.id, chatId, createdByUserId: actor.userId }, + data: { + organizationId: project.organizationId, + projectId: project.id, + chatId, + createdByUserId: actor.userId, + selectedAgentRoleId: defaultRole.id, + }, }); await replaceProjectGrant(tx, { projectId: project.id, @@ -390,8 +398,15 @@ async function createManagedProject( createdByUserId: input.actorUserId, }); if (input.chatId !== undefined) { + const defaultRole = await requireDefaultAgentRole(tx, organization.id); await tx.projectGroupBinding.create({ - data: { projectId: created.id, chatId: input.chatId, createdByUserId: input.actorUserId }, + data: { + organizationId: organization.id, + projectId: created.id, + chatId: input.chatId, + createdByUserId: input.actorUserId, + selectedAgentRoleId: defaultRole.id, + }, }); await replaceProjectGrant(tx, { projectId: created.id, @@ -566,6 +581,21 @@ async function assertFolderInOrganization( } } +async function requireDefaultAgentRole( + tx: Prisma.TransactionClient, + organizationId: string, +): Promise<{ readonly id: string }> { + const roles = await tx.organizationAgentRole.findMany({ + where: { organizationId, isDefault: true, disabledAt: null }, + select: { id: true }, + take: 2, + }); + if (roles.length !== 1) { + throw new Error(`organization ${organizationId} must have exactly one active default Agent role`); + } + return roles[0]!; +} + async function requireActiveOrganization(prisma: PrismaClient, organizationId: string): Promise { const organization = await prisma.organization.findUnique({ where: { id: organizationId }, diff --git a/hub/src/settings/runtime.ts b/hub/src/settings/runtime.ts index 4b68640..c9a06b7 100644 --- a/hub/src/settings/runtime.ts +++ b/hub/src/settings/runtime.ts @@ -177,6 +177,10 @@ export class DatabaseRuntimeSettings implements RuntimeSettings { if (project.organization.agentRoles.length === 0) { throw new Error(`no active Agent roles configured for organization ${project.organization.id}`); } + const defaultRoles = project.organization.agentRoles.filter((role) => role.isDefault); + if (defaultRoles.length !== 1) { + throw new Error(`organization ${project.organization.id} must have exactly one active default Agent role`); + } const defaults = await this.envSettings.modelRegistry(scope); const enabledModels = new Set(defaults.listModels().map((model) => model.id)); @@ -206,9 +210,6 @@ export class DatabaseRuntimeSettings implements RuntimeSettings { }; }), })); - if (!roles.some((role) => role.id === "draft")) { - throw new Error(`default Agent role draft is not configured for organization ${project.organization.id}`); - } return new InMemoryModelRegistry(defaults.listModels(), roles); } diff --git a/hub/test/integration/admin-explorer.test.ts b/hub/test/integration/admin-explorer.test.ts index 3871ee8..2968545 100644 --- a/hub/test/integration/admin-explorer.test.ts +++ b/hub/test/integration/admin-explorer.test.ts @@ -206,7 +206,12 @@ describe("admin explorer API", () => { it("blocks cross-org project access by id", async () => { const token = await seedAdmin(); await prisma.organization.create({ - data: { id: "org_other", slug: "other", name: "Other" }, + data: { + id: "org_other", + slug: "other", + name: "Other", + agentRoles: { create: { roleId: "draft", label: "草稿", isDefault: true } }, + }, }); await prisma.project.create({ data: { diff --git a/hub/test/integration/agent-configuration.test.ts b/hub/test/integration/agent-configuration.test.ts index 384d614..9568e37 100644 --- a/hub/test/integration/agent-configuration.test.ts +++ b/hub/test/integration/agent-configuration.test.ts @@ -60,7 +60,10 @@ describe("Organization Agent configuration management", () => { expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]); expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]); await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } })) - .resolves.toMatchObject({ archivedAt: expect.any(Date) }); + .resolves.toMatchObject({ + archivedAt: expect.any(Date), + metadata: expect.objectContaining({ userResumable: false }), + }); }); it("rejects unknown, disabled and cross-Organization skills", async () => { @@ -81,6 +84,40 @@ describe("Organization Agent configuration management", () => { })).rejects.toThrow("active skills not found in organization"); }); + it("switches the Organization default role atomically", async () => { + await configuration.upsertRole({ + organizationId: DEFAULT_ORG_ID, + roleId: "review", + label: "审校", + tools: ["read_file"], + isDefault: true, + }); + + await expect(prisma.organizationAgentRole.findMany({ + where: { organizationId: DEFAULT_ORG_ID, isDefault: true, disabledAt: null }, + select: { roleId: true }, + })).resolves.toEqual([{ roleId: "review" }]); + await expect(configuration.upsertRole({ + organizationId: DEFAULT_ORG_ID, + roleId: "review", + label: "审校", + isDefault: false, + })).rejects.toThrow("cannot unset the active default role without selecting a replacement"); + }); + + it("rejects a zero-default committed state at the database boundary", async () => { + await expect(prisma.organizationAgentRole.updateMany({ + where: { organizationId: DEFAULT_ORG_ID, isDefault: true, disabledAt: null }, + data: { isDefault: false }, + })).rejects.toThrow("must have exactly one active default Agent role"); + }); + + it("rejects creating an Organization without its default role in the same transaction", async () => { + await expect(prisma.organization.create({ + data: { id: "org_without_role", slug: "without-role", name: "Without Role" }, + })).rejects.toThrow("must have exactly one active default Agent role"); + }); + async function makeSkill(parent: string, name: string): Promise { const source = join(parent, "sources", name); await mkdir(source, { recursive: true }); diff --git a/hub/test/integration/agent-runtime-config.test.ts b/hub/test/integration/agent-runtime-config.test.ts index 2664d4c..308cadb 100644 --- a/hub/test/integration/agent-runtime-config.test.ts +++ b/hub/test/integration/agent-runtime-config.test.ts @@ -42,22 +42,18 @@ describe("Organization-scoped Agent runtime configuration", () => { }), ]); const [roleA, roleB] = await Promise.all([ - prisma.organizationAgentRole.create({ + prisma.organizationAgentRole.update({ + where: { organizationId_roleId: { organizationId: DEFAULT_ORG_ID, roleId: "draft" } }, data: { - id: "role-a", - organizationId: DEFAULT_ORG_ID, - roleId: "draft", label: "A Draft", defaultModel: "anthropic/claude-sonnet-5", systemPrompt: "prompt-a", tools: ["read_file", "cph_build"], }, }), - prisma.organizationAgentRole.create({ + prisma.organizationAgentRole.update({ + where: { organizationId_roleId: { organizationId: "org_other", roleId: "draft" } }, data: { - id: "role-b", - organizationId: "org_other", - roleId: "draft", label: "B Draft", systemPrompt: "prompt-b", tools: [], @@ -105,8 +101,8 @@ describe("Organization-scoped Agent runtime configuration", () => { disabledAt: new Date(), }, }); - const role = await prisma.organizationAgentRole.create({ - data: { id: "role-a", organizationId: DEFAULT_ORG_ID, roleId: "draft", label: "Draft" }, + const role = await prisma.organizationAgentRole.findUniqueOrThrow({ + where: { organizationId_roleId: { organizationId: DEFAULT_ORG_ID, roleId: "draft" } }, }); await prisma.organizationAgentRoleSkill.create({ data: { organizationId: DEFAULT_ORG_ID, agentRoleId: role.id, agentSkillId: skill.id }, diff --git a/hub/test/integration/feishu-binding-lifecycle.test.ts b/hub/test/integration/feishu-binding-lifecycle.test.ts index d3deec6..278b1df 100644 --- a/hub/test/integration/feishu-binding-lifecycle.test.ts +++ b/hub/test/integration/feishu-binding-lifecycle.test.ts @@ -118,7 +118,12 @@ describe("Feishu binding lifecycle events", () => { reason: "bot_removed", }); await prisma.projectGroupBinding.create({ - data: { projectId: "project-rebound", chatId: "chat-rebound" }, + data: { + organizationId: DEFAULT_ORG_ID, + projectId: "project-rebound", + chatId: "chat-rebound", + selectedAgentRoleId: `agent_role_draft_${DEFAULT_ORG_ID}`, + }, }); await expect(archiveFeishuBindingForLifecycleEvent(prisma, { diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts index 9d0afee..e68f4b1 100644 --- a/hub/test/integration/helpers.ts +++ b/hub/test/integration/helpers.ts @@ -55,19 +55,33 @@ export async function seedTestOrganization( id: string = DEFAULT_ORG_ID, slug: string = "test-default", ): Promise { - await prisma.organization.upsert({ - where: { id }, - update: {}, - create: { - id, - slug, - name: "Test Default Organization", - }, - }); - await prisma.organizationProjectSettings.upsert({ - where: { organizationId: id }, - update: {}, - create: { organizationId: id, membersCanCreateProjects: true }, + await prisma.$transaction(async (tx) => { + await tx.organization.upsert({ + where: { id }, + update: {}, + create: { id, slug, name: "Test Default Organization" }, + }); + await tx.organizationProjectSettings.upsert({ + where: { organizationId: id }, + update: {}, + create: { organizationId: id, membersCanCreateProjects: true }, + }); + const defaultRole = await tx.organizationAgentRole.upsert({ + where: { organizationId_roleId: { organizationId: id, roleId: "draft" } }, + update: { label: "草稿", isDefault: true, disabledAt: null }, + create: { + id: `agent_role_draft_${id}`, + organizationId: id, + roleId: "draft", + label: "草稿", + sortOrder: 10, + isDefault: true, + }, + }); + await tx.organizationAgentRole.updateMany({ + where: { organizationId: id, id: { not: defaultRole.id }, isDefault: true }, + data: { isDefault: false }, + }); }); const inbox = await prisma.folder.findFirst({ where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null }, @@ -400,6 +414,12 @@ export async function seedProject( }, }); await prisma.projectGroupBinding.create({ - data: { projectId, chatId, createdByUserId: "u_" + projectId }, + data: { + organizationId: DEFAULT_ORG_ID, + projectId, + chatId, + createdByUserId: "u_" + projectId, + selectedAgentRoleId: `agent_role_draft_${DEFAULT_ORG_ID}`, + }, }); } diff --git a/hub/test/integration/project-onboarding.test.ts b/hub/test/integration/project-onboarding.test.ts index 74cd5d1..0866b66 100644 --- a/hub/test/integration/project-onboarding.test.ts +++ b/hub/test/integration/project-onboarding.test.ts @@ -262,6 +262,30 @@ describe("ADR-0021 project onboarding", () => { }); expect(oldChatGrant).toBeNull(); }); + + it("rejects selecting an Agent role from another Organization at the database boundary", async () => { + await seedUser("u-owner", "ou_owner", "OWNER"); + const project = await createProjectFromOrgAdmin(prisma, { + organizationId: DEFAULT_ORG_ID, + actorFeishuOpenId: "ou_owner", + name: "Tenant-scoped role project", + workspaceRoot: await tempWorkspaceRoot(), + }); + await bindFeishuChatToProject(prisma, { + projectId: project.projectId, + actorFeishuOpenId: "ou_owner", + chatId: "chat-tenant-role", + }); + await seedTestOrganization("org_other", "other"); + const otherRole = await prisma.organizationAgentRole.findUniqueOrThrow({ + where: { organizationId_roleId: { organizationId: "org_other", roleId: "draft" } }, + }); + + await expect(prisma.projectGroupBinding.updateMany({ + where: { projectId: project.projectId, chatId: "chat-tenant-role", archivedAt: null }, + data: { selectedAgentRoleId: otherRole.id }, + })).rejects.toThrow(); + }); }); async function seedUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise { diff --git a/hub/test/integration/silo-bootstrap.test.ts b/hub/test/integration/silo-bootstrap.test.ts index e611148..a5e357e 100644 --- a/hub/test/integration/silo-bootstrap.test.ts +++ b/hub/test/integration/silo-bootstrap.test.ts @@ -23,6 +23,7 @@ describe("Alpha Silo bootstrap", () => { id: "org_default", slug: "legacy-default", name: "Legacy Default Organization", + agentRoles: { create: { roleId: "draft", label: "草稿", isDefault: true } }, projectSettings: { create: { membersCanCreateProjects: true } }, folders: { create: { @@ -56,10 +57,10 @@ describe("Alpha Silo bootstrap", () => { await expect(prisma.organizationAgentRole.findMany({ where: { organizationId: "org_alpha", disabledAt: null }, orderBy: { sortOrder: "asc" }, - select: { roleId: true, label: true }, + select: { roleId: true, label: true, isDefault: true }, })).resolves.toEqual([ - { roleId: "draft", label: "草稿" }, - { roleId: "review", label: "审校" }, + { roleId: "draft", label: "草稿", isDefault: true }, + { roleId: "review", label: "审校", isDefault: false }, ]); const persisted = JSON.stringify({ diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index 25b298c..410b2cb 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -13,7 +13,6 @@ import { silentLogger, } from "./helpers.js"; import { InMemoryModelRegistry } from "../../src/agent/models.js"; -import { createSlashCommandRegistry } from "../../src/feishu/slashCommands.js"; import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js"; import { TriggerQueue } from "../../src/feishu/triggerQueue.js"; import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js"; @@ -633,6 +632,40 @@ describe("trigger full lifecycle (integration)", () => { expectPromptFromSender(runAgentCalls[0]?.prompt, "ou_test_user", "第一段\n第二段"); }); + it("partitions batched messages by the role selected when each message arrives", async () => { + await seedProject("proj-batch-role", "chat-batch-role"); + const reviewRole = await prisma.organizationAgentRole.create({ + data: { + organizationId: DEFAULT_ORG_ID, + roleId: "review", + label: "审校", + defaultModel: "mock-model", + tools: ["read_file"], + }, + }); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + messageBatcherOptions: { debounceMs: 10 }, + }); + + await trigger(makeEvent("chat-batch-role", "@_user_1 草稿消息"), rt); + await prisma.projectGroupBinding.updateMany({ + where: { projectId: "proj-batch-role", chatId: "chat-batch-role", archivedAt: null }, + data: { selectedAgentRoleId: reviewRole.id }, + }); + await trigger(makeEvent("chat-batch-role", "@_user_1 审校消息"), rt); + + await vi.waitFor(async () => { + expect(await prisma.agentRun.count({ where: { projectId: "proj-batch-role", status: "COMPLETED" } })).toBe(2); + }); + const runs = await prisma.agentRun.findMany({ where: { projectId: "proj-batch-role" } }); + expect(runs.find((run) => run.prompt.includes("草稿消息"))?.metadata).toMatchObject({ roleId: "draft" }); + expect(runs.find((run) => run.prompt.includes("审校消息"))?.metadata).toMatchObject({ roleId: "review" }); + }); + it("keeps the project session shared while labeling each sender in the prompt", async () => { await seedProject("proj-speaker", "chat-speaker"); await prisma.permissionGrant.create({ @@ -677,24 +710,7 @@ describe("trigger full lifecycle (integration)", () => { expect(sessions).toHaveLength(1); }); - it("/new bypasses message batching", async () => { - await seedProject("proj-1c", "chat-1c"); - const trigger = makeTriggerHandler({ - prisma, - settings, - logger: silentLogger, - runAgent, - messageBatcherOptions: { debounceMs: 10_000 }, - }); - - await trigger(makeEvent("chat-1c", "@_user_1 /new"), rt); - - expect(rt.sentTexts).toContain("已开新会话,下次 @bot 将从头开始。"); - expect(runAgentCalls).toHaveLength(0); - expect(await prisma.agentRun.findMany()).toHaveLength(0); - }); - - it("/help lists control and role commands without creating a run", async () => { + it("/help lists only the closed Hub slash protocol", async () => { await seedProject("proj-help", "chat-help"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); @@ -702,16 +718,14 @@ describe("trigger full lifecycle (integration)", () => { const helpText = rt.sentTexts.at(-1) ?? ""; expect(helpText).toContain("可用 slash 命令"); - expect(helpText).toContain("/new"); - expect(helpText).toContain("/reset"); expect(helpText).toContain("/project"); - expect(helpText).toContain("/draft <需求>"); - expect(helpText).toContain("/review <需求>"); + expect(helpText).toContain("/usage"); + expect(helpText).not.toMatch(/\/(?:new|reset|resume|draft|review|compact)\b/); expect(runAgentCalls).toHaveLength(0); expect(await prisma.agentRun.findMany()).toHaveLength(0); }); - it("/cost reports recorded current-session cost without creating a run", async () => { + it("/usage reports the selected role's active-session cost", async () => { await seedProject("proj-cost", "chat-cost"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); @@ -722,151 +736,25 @@ describe("trigger full lifecycle (integration)", () => { expect(runs[0]?.status).toBe("COMPLETED"); }); - await trigger(makeEvent("chat-cost", "@_user_1 /cost"), rt); + await trigger(makeEvent("chat-cost", "@_user_1 /usage"), rt); const costText = rt.sentTexts.at(-1) ?? ""; - expect(costText).toContain("当前会话已记录 agent 成本"); - expect(costText).toContain("总计: $0.0023"); - expect(costText).toContain("Runs: 1 已记录"); + expect(costText).toContain("当前角色会话用量"); + expect(costText).toContain("$0.0023"); expect(costText).toContain("openrouter / mock-model"); expect(runAgentCalls).toHaveLength(1); expect(await prisma.agentRun.findMany()).toHaveLength(1); }); - it("/cost surfaces finished runs without recorded cost", async () => { - await seedProject("proj-cost-missing", "chat-cost-missing"); - const session = await prisma.agentSession.create({ - data: { - projectId: "proj-cost-missing", - provider: "openrouter", - roleId: "draft", - model: "mock-model", - metadata: {}, - }, - select: { id: true }, - }); - await prisma.agentRun.create({ - data: { - projectId: "proj-cost-missing", - sessionId: session.id, - entrypoint: "FEISHU", - status: "COMPLETED", - prompt: "old test run", - model: "mock-model", - provider: "openrouter", - inputTokens: 10, - outputTokens: 5, - metadata: {}, - finishedAt: new Date(), - }, - }); - const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-cost-missing", "@_user_1 /cost"), rt); - - const costText = rt.sentTexts.at(-1) ?? ""; - expect(costText).toContain("还没有任何 run 记录到真实成本"); - expect(costText).toContain("未记录成本: 1 runs"); - expect(runAgentCalls).toHaveLength(0); - }); - - it("/help is built from the current registry on each request", async () => { - await seedProject("proj-help-live", "chat-help-live"); - let currentModels = new InMemoryModelRegistry( - [{ id: "mock-model", label: "Mock", toolCapable: true }], - [{ id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined }], - ); - const dynamicSettings: RuntimeSettings = { - async provider(providerId, scope) { - return settings.provider(providerId, scope); - }, - async modelRegistry() { - return currentModels; - }, - async runPolicy(input) { - return settings.runPolicy(input); - }, - }; - const trigger = makeTriggerHandler({ prisma, settings: dynamicSettings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-help-live", "@_user_1 /help"), rt); - expect(rt.sentTexts.at(-1) ?? "").not.toContain("/coach <需求>"); - - currentModels = new InMemoryModelRegistry( - [{ id: "mock-model", label: "Mock", toolCapable: true }], - [ - { id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined }, - { id: "coach", label: "教练", defaultModel: "mock-model", systemPrompt: undefined, tools: [] }, - ], - ); - - await trigger(makeEvent("chat-help-live", "@_user_1 /help"), rt); - - const helpText = rt.sentTexts.at(-1) ?? ""; - expect(helpText).toContain("/coach <需求>"); - expect(runAgentCalls).toHaveLength(0); - expect(await prisma.agentRun.findMany()).toHaveLength(0); - }); - it("/help returns command-specific help", async () => { - await seedProject("proj-help-reset", "chat-help-reset"); + await seedProject("proj-help-usage", "chat-help-usage"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - await trigger(makeEvent("chat-help-reset", "@_user_1 /help reset"), rt); + await trigger(makeEvent("chat-help-usage", "@_user_1 /help usage"), rt); const helpText = rt.sentTexts.at(-1) ?? ""; - expect(helpText).toContain("/reset"); - expect(helpText).toContain("清空当前项目已经排队"); - expect(helpText).toContain("/reset help"); - expect(runAgentCalls).toHaveLength(0); - expect(await prisma.agentRun.findMany()).toHaveLength(0); - }); - - it("passes the selected role tool whitelist into the runner", async () => { - await seedProject("proj-role-tools", "chat-role-tools"); - const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-role-tools", "@_user_1 /review 检查讲义"), rt); - - await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(1); - expect(runs[0]?.status).toBe("COMPLETED"); - }); - - expect(runAgentCalls).toHaveLength(1); - expect(runAgentCalls[0]?.tools).toEqual(["read_file"]); - }); - - it("control commands support a help subcommand", async () => { - await seedProject("proj-new-help", "chat-new-help"); - const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-new-help", "@_user_1 /new help"), rt); - - const helpText = rt.sentTexts.at(-1) ?? ""; - expect(helpText).toContain("/new"); - expect(helpText).toContain("归档当前未归档"); - expect(helpText).toContain("/help new"); - expect(rt.sentTexts).not.toContain("已开新会话,下次 @bot 将从头开始。"); - expect(runAgentCalls).toHaveLength(0); - expect(await prisma.agentRun.findMany()).toHaveLength(0); - }); - - it("role commands support a help subcommand without consuming role grants", async () => { - await seedProject("proj-role-help", "chat-role-help"); - await prisma.roleTriggerGrant.create({ - data: { projectId: "proj-role-help", roleId: "review", principalType: "USER", principalId: "ou_other" }, - }); - const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-role-help", "@_user_1 /review help"), rt); - - const helpText = rt.sentTexts.at(-1) ?? ""; - expect(helpText).toContain("/review"); - expect(helpText).toContain("使用“审校”角色"); - expect(helpText).toContain("工具范围: read_file"); - expect(rt.sentTexts).not.toContain("无权限使用角色 review。"); + expect(helpText).toContain("/usage"); + expect(helpText).toContain("真实 Agent 用量与成本"); expect(runAgentCalls).toHaveLength(0); expect(await prisma.agentRun.findMany()).toHaveLength(0); }); @@ -958,7 +846,7 @@ describe("trigger full lifecycle (integration)", () => { }); it.each(["SUSPENDED", "ARCHIVED"] as const)( - "rejects triggers and resume commands when the organization is %s", + "rejects triggers when the organization is %s", async (status) => { await seedProject(`proj-org-${status}`, `chat-org-${status}`); const session = await prisma.agentSession.create({ @@ -974,7 +862,7 @@ describe("trigger full lifecycle (integration)", () => { await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - await trigger(makeEvent(`chat-org-${status}`, "@_user_1 /resume"), rt); + await trigger(makeEvent(`chat-org-${status}`, "@_user_1 写教案"), rt); expect(rt.sentTexts).toContain("无权限触发。"); expect(runAgentCalls).toHaveLength(0); @@ -985,87 +873,6 @@ describe("trigger full lifecycle (integration)", () => { }, ); - it.each(["SUSPENDED", "ARCHIVED"] as const)( - "rejects direct session mutation when the organization is %s", - async (status) => { - await seedProject(`proj-direct-${status}`, `chat-direct-${status}`); - const session = await prisma.agentSession.create({ - data: { - projectId: `proj-direct-${status}`, - provider: "openrouter", - roleId: "draft", - model: "mock-model", - metadata: {}, - archivedAt: new Date(), - }, - }); - await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } }); - const commands = createSlashCommandRegistry({ - prisma, - settings, - logger: silentLogger, - triggerQueue: new TriggerQueue(), - }); - const resume = commands.get("resume"); - expect(resume).toBeDefined(); - - await expect(resume!.run({ - invocation: { name: "resume", args: [] }, - projectId: `proj-direct-${status}`, - chatId: `chat-direct-${status}`, - rt, - })).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`); - - await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({ - archivedAt: expect.any(Date), - }); - }, - ); - - it("reports a lifecycle race that rejects a slash-command mutation", async () => { - await seedProject("proj-slash-race", "chat-slash-race"); - const session = await prisma.agentSession.create({ - data: { - projectId: "proj-slash-race", - provider: "openrouter", - roleId: "draft", - model: "mock-model", - metadata: {}, - archivedAt: new Date(), - }, - }); - await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } }); - const trigger = makeTriggerHandler({ - prisma, - settings, - logger: silentLogger, - runAgent, - messageBatcherOptions: { maxMessages: 1 }, - authorizer: { - async can(request) { - return { - allowed: true, - reason: "authorized before concurrent suspension", - action: request.action, - resource: request.resource, - actor: request.actor, - organizationId: DEFAULT_ORG_ID, - principals: [{ type: "USER", id: "ou_test_user" }], - requiredRole: "EDIT", - effectiveRole: "EDIT", - }; - }, - }, - }); - - await trigger(makeEvent("chat-slash-race", "@_user_1 /resume"), rt); - - expect(rt.sentTexts).toContain("组织当前不可用,拒绝操作。"); - await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({ - archivedAt: expect.any(Date), - }); - }); - it("queues a text trigger when project is already locked (ADR-0002)", async () => { await seedProject("proj-3", "chat-3"); // Manually create a lock by inserting a run + lock. @@ -1132,6 +939,56 @@ describe("trigger full lifecycle (integration)", () => { }); }); + it("freezes the selected role when a trigger enters the queue", async () => { + await seedProject("proj-queue-role", "chat-queue-role"); + const reviewRole = await prisma.organizationAgentRole.create({ + data: { + organizationId: DEFAULT_ORG_ID, + roleId: "review", + label: "审校", + defaultModel: "mock-model", + tools: ["read_file"], + }, + }); + const firstRun = deferred(); + const secondRun = deferred(); + const pendingRuns = [firstRun, secondRun]; + const queuedRunAgent: TestRunner = async (req) => { + runAgentCalls.push(req); + const pending = pendingRuns.shift(); + if (pending === undefined) throw new Error("unexpected extra run"); + return pending.promise; + }; + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent: queuedRunAgent, + messageBatcherOptions: { maxMessages: 1 }, + }); + + await trigger(makeEvent("chat-queue-role", "@_user_1 第一个请求"), rt); + await vi.waitFor(() => expect(runAgentCalls).toHaveLength(1)); + await trigger(makeEvent("chat-queue-role", "@_user_1 排队的草稿请求"), rt); + await prisma.projectGroupBinding.updateMany({ + where: { projectId: "proj-queue-role", chatId: "chat-queue-role", archivedAt: null }, + data: { selectedAgentRoleId: reviewRole.id }, + }); + + firstRun.resolve(completedRunResult("first done", "sdk-session-first")); + await vi.waitFor(() => expect(runAgentCalls).toHaveLength(2)); + const queuedRun = await prisma.agentRun.findFirstOrThrow({ + where: { projectId: "proj-queue-role", prompt: { contains: "排队的草稿请求" } }, + }); + expect(queuedRun.metadata).toMatchObject({ roleId: "draft" }); + expect(runAgentCalls[1]?.tools).toBeUndefined(); + + secondRun.resolve(completedRunResult("second done", "sdk-session-second")); + await vi.waitFor(async () => { + expect(await prisma.agentRun.count({ where: { projectId: "proj-queue-role", status: "COMPLETED" } })).toBe(2); + }); + }); + it("reauthorizes a queued trigger and drops it after the organization is suspended", async () => { await seedProject("proj-queue-suspended", "chat-queue-suspended"); const firstRun = deferred(); @@ -1331,173 +1188,174 @@ describe("trigger full lifecycle (integration)", () => { expect(runs).toHaveLength(0); }); - it("/new archives current session (no run created)", async () => { + it("/project opens the role and session console without creating a run", async () => { await seedProject("proj-6", "chat-6"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - // First @bot creates a session + run. - await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt); - await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(1); - expect(runs[0]?.status).toBe("COMPLETED"); - }); + await trigger(makeEvent("chat-6", "@_user_1 /project"), rt); - // /new archives the session. - await trigger(makeEvent("chat-6", "@_user_1 /new"), rt); - expect(rt.sentTexts).toContain("已开新会话,下次 @bot 将从头开始。"); - - const sessions = await prisma.agentSession.findMany(); - expect(sessions).toHaveLength(1); - expect(sessions[0]?.archivedAt).not.toBeNull(); - // No new run created for /new. - expect(await prisma.agentRun.findMany()).toHaveLength(1); + expect(rt.sentCards).toHaveLength(1); + const card = JSON.stringify(rt.sentCards[0]); + expect(card).toContain("当前角色"); + expect(card).toContain("草稿"); + expect(card).toContain("新开会话"); + expect(card).toContain("历史会话"); + expect(runAgentCalls).toHaveLength(0); + expect(await prisma.agentRun.count()).toBe(0); }); - it("/resume un-archives the most recent session", async () => { - await seedProject("proj-7", "chat-7"); + it("creates, lists, and resumes the selected role's user-managed session history", async () => { + await seedProject("proj-session-console", "chat-session-console"); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); + await trigger(makeEvent("chat-session-console", "@_user_1 建立历史会话"), rt); + await vi.waitFor(async () => { + await expect(prisma.agentRun.findFirstOrThrow({ where: { projectId: "proj-session-console" } })) + .resolves.toMatchObject({ status: "COMPLETED" }); + }); + const session = await prisma.agentSession.findFirstOrThrow({ where: { projectId: "proj-session-console" } }); + + await trigger.onCardAction(makeOnboardingEvent("chat-session-console", { + project_onboarding: { + action: "new_agent_session", + organization_id: DEFAULT_ORG_ID, + project_id: "proj-session-console", + }, + }, "ou_test_user"), rt); + await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })) + .resolves.toMatchObject({ archivedAt: expect.any(Date), metadata: expect.objectContaining({ userResumable: true }) }); + + await trigger.onCardAction(makeOnboardingEvent("chat-session-console", { + project_onboarding: { + action: "show_session_history", + organization_id: DEFAULT_ORG_ID, + project_id: "proj-session-console", + }, + }, "ou_test_user"), rt); + expect(JSON.stringify(rt.sentPatches.at(-1))).toContain(session.id); + + await trigger.onCardAction(makeOnboardingEvent("chat-session-console", { + project_onboarding: { + action: "resume_agent_session", + organization_id: DEFAULT_ORG_ID, + project_id: "proj-session-console", + session_id: session.id, + }, + }, "ou_test_user"), rt); + await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })) + .resolves.toMatchObject({ archivedAt: null, metadata: expect.objectContaining({ userResumable: false }) }); + }); + + it("switches the bound role from the project card and uses it for later messages", async () => { + await seedProject("proj-role-switch", "chat-role-switch"); + const reviewRole = await prisma.organizationAgentRole.create({ + data: { + organizationId: DEFAULT_ORG_ID, + roleId: "review", + label: "审校", + defaultModel: "mock-model", + tools: ["read_file"], + sortOrder: 10, + }, + }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - // Create + archive a session via /new. - await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt); + await trigger.onCardAction(makeOnboardingEvent("chat-role-switch", { + project_onboarding: { + action: "select_agent_role", + organization_id: DEFAULT_ORG_ID, + project_id: "proj-role-switch", + agent_role_id: reviewRole.id, + }, + }, "ou_test_user"), rt); + await trigger(makeEvent("chat-role-switch", "@_user_1 检查讲义"), rt); + await vi.waitFor(async () => { - expect(await prisma.agentRun.findMany()).toHaveLength(1); + expect(await prisma.agentRun.count()).toBe(1); }); - await trigger(makeEvent("chat-7", "@_user_1 /new"), rt); - - // /resume un-archives. - await trigger(makeEvent("chat-7", "@_user_1 /resume"), rt); - expect(rt.sentTexts).toContain("已恢复上一个会话。"); - - const sessions = await prisma.agentSession.findMany(); - expect(sessions).toHaveLength(1); - expect(sessions[0]?.archivedAt).toBeNull(); + expect(runAgentCalls[0]?.tools).toEqual(["read_file"]); + await expect(prisma.agentRun.findFirstOrThrow({ where: { projectId: "proj-role-switch" } })).resolves.toMatchObject({ + metadata: expect.objectContaining({ roleId: "review" }), + }); + await expect(prisma.projectGroupBinding.findFirstOrThrow({ + where: { projectId: "proj-role-switch", chatId: "chat-role-switch", archivedAt: null }, + select: { selectedAgentRoleId: true }, + })).resolves.toEqual({ selectedAgentRoleId: reviewRole.id }); }); - it("/reset archives current session", async () => { - await seedProject("proj-8", "chat-8"); - const queue = new TriggerQueue(); - const trigger = makeTriggerHandler({ - prisma, - settings, - logger: silentLogger, - runAgent, - messageBatcherOptions: { maxMessages: 1 }, - triggerQueue: queue, - }); - - await trigger(makeEvent("chat-8", "@_user_1 写教案"), rt); - await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(1); - expect(runs[0]?.status).toBe("COMPLETED"); - }); - - const queuedEvent = makeEvent("chat-8", "@_user_1 后续需求"); - queue.enqueue("proj-8", { - chatId: "chat-8", - prompt: extractPrompt(queuedEvent.message) ?? "后续需求", - msg: queuedEvent.message, - senderOpenId: "ou_test_user", - actor: { feishuOpenId: "ou_test_user", chatId: "chat-8" }, - }); - expect(queue.length("proj-8")).toBe(1); - - await trigger(makeEvent("chat-8", "@_user_1 /reset"), rt); - expect(rt.sentTexts).toContain("已重置,下次 @bot 将从头开始。"); - expect(queue.length("proj-8")).toBe(0); - - const sessions = await prisma.agentSession.findMany(); - expect(sessions).toHaveLength(1); - expect(sessions[0]?.archivedAt).not.toBeNull(); - }); - - it("unknown slash command falls through to agent", async () => { + it("unknown slash commands fail visibly instead of reaching the Agent", async () => { await seedProject("proj-9", "chat-9"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt); - // Should create a run (falls through as a normal prompt). - await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(1); - expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "/unknown"); - }); - }); - - it("denies /review when sender has no role grant (per-role gate)", async () => { - await seedProject("proj-10", "chat-10"); - // Someone else holds review; ou_test_user does not. - await prisma.roleTriggerGrant.create({ - data: { projectId: "proj-10", roleId: "review", principalType: "USER", principalId: "ou_other" }, - }); - const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt); - - expect(rt.sentTexts).toContain("无权限使用角色 review。"); + expect(rt.sentTexts.at(-1)).toContain("未知 slash 命令 /unknown"); expect(runAgentCalls).toHaveLength(0); - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(0); + expect(await prisma.agentRun.count()).toBe(0); }); - it("allows /review when sender holds the role grant", async () => { - await seedProject("proj-11", "chat-11"); - await prisma.roleTriggerGrant.create({ - data: { projectId: "proj-11", roleId: "review", principalType: "USER", principalId: "ou_test_user" }, - }); + it("sends native /compact as an exact SDK prompt for the current role session", async () => { + await seedProject("proj-compact", "chat-compact"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - await trigger(makeEvent("chat-11", "@_user_1 /review 看看这节"), rt); - + await trigger(makeEvent("chat-compact", "@_user_1 写第三单元"), rt); await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(1); - expect(runs[0]?.status).toBe("COMPLETED"); - expect(runs[0]?.metadata).toMatchObject({ roleId: "review" }); + await expect(prisma.agentRun.findFirstOrThrow({ where: { projectId: "proj-compact" } })) + .resolves.toMatchObject({ status: "COMPLETED" }); }); + const session = await prisma.agentSession.findFirstOrThrow({ where: { projectId: "proj-compact", archivedAt: null } }); + + await trigger.onCardAction(makeOnboardingEvent("chat-compact", { + project_onboarding: { + action: "compact_agent_session", + organization_id: DEFAULT_ORG_ID, + project_id: "proj-compact", + }, + }, "ou_test_user"), rt); + await vi.waitFor(async () => { + expect(await prisma.agentRun.count()).toBe(2); + }); + + expect(runAgentCalls[1]?.prompt).toBe("/compact"); + expect(runAgentCalls[1]?.resumeSessionId).toBe("sdk-session-1"); + expect(runAgentCalls[1]?.sessionId).toBe(session.id); + await expect(prisma.agentRun.findFirstOrThrow({ + where: { projectId: "proj-compact", prompt: "/compact" }, + })).resolves.toMatchObject({ metadata: expect.objectContaining({ roleId: "draft" }) }); }); - it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => { - await seedProject("proj-12", "chat-12"); - const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt); + it("preserves native /compact semantics while the action waits in the project queue", async () => { + await seedProject("proj-compact-queued", "chat-compact-queued"); + const activeRun = deferred(); + const queuedRunner: TestRunner = async (req) => { + runAgentCalls.push(req); + if (runAgentCalls.length === 2) return activeRun.promise; + return completedRunResult(`done ${runAgentCalls.length}`, "sdk-session-queued"); + }; + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent: queuedRunner, + messageBatcherOptions: { maxMessages: 1 }, + }); + await trigger(makeEvent("chat-compact-queued", "@_user_1 建立会话"), rt); await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(1); - expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "写第三单元"); - expect(runs[0]?.metadata).toMatchObject({ roleId: "draft" }); + expect(await prisma.agentRun.count({ where: { projectId: "proj-compact-queued", status: "COMPLETED" } })).toBe(1); }); - }); + await trigger(makeEvent("chat-compact-queued", "@_user_1 正在处理"), rt); + await vi.waitFor(() => expect(runAgentCalls).toHaveLength(2)); + await trigger.onCardAction(makeOnboardingEvent("chat-compact-queued", { + project_onboarding: { + action: "compact_agent_session", + organization_id: DEFAULT_ORG_ID, + project_id: "proj-compact-queued", + }, + }, "ou_test_user"), rt); + expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始"); - it("keeps role sessions separate even when roles share a model", async () => { - await seedProject("proj-12b", "chat-12b"); - await prisma.roleTriggerGrant.create({ - data: { projectId: "proj-12b", roleId: "review", principalType: "USER", principalId: "ou_test_user" }, - }); - const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-12b", "@_user_1 /draft 写第三单元"), rt); - await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(1); - expect(runs[0]?.status).toBe("COMPLETED"); - }); - - await trigger(makeEvent("chat-12b", "@_user_1 /review 看看这节"), rt); - await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(2); - expect(runs.every((run) => run.status === "COMPLETED")).toBe(true); - }); - - const sessions = await prisma.agentSession.findMany({ orderBy: { roleId: "asc" } }); - expect(sessions.map((session) => session.roleId)).toEqual(["draft", "review"]); - expect(new Set(sessions.map((session) => session.model))).toEqual(new Set(["mock-model"])); - expect(new Set(sessions.map((session) => session.id)).size).toBe(2); - expect(runAgentCalls[1]?.resumeSessionId).toBeUndefined(); + activeRun.resolve(completedRunResult("active done", "sdk-session-active")); + await vi.waitFor(() => expect(runAgentCalls).toHaveLength(3)); + expect(runAgentCalls[2]?.prompt).toBe("/compact"); }); it("dedups a redelivered event by event_id (no second run)", async () => { diff --git a/hub/test/unit/feishu-reactions.test.ts b/hub/test/unit/feishu-reactions.test.ts index 68656a0..1583030 100644 --- a/hub/test/unit/feishu-reactions.test.ts +++ b/hub/test/unit/feishu-reactions.test.ts @@ -274,7 +274,7 @@ function mockPrisma(): PrismaClient { create: vi.fn(async () => ({ id: "receipt-1" })), }, projectGroupBinding: { - findFirst: vi.fn(async () => ({ projectId: "project-1" })), + findFirst: vi.fn(async () => ({ projectId: "project-1", selectedRole: { roleId: "draft" } })), }, project: { findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })), diff --git a/hub/test/unit/slash-commands.test.ts b/hub/test/unit/slash-commands.test.ts index 6a91924..3dd222d 100644 --- a/hub/test/unit/slash-commands.test.ts +++ b/hub/test/unit/slash-commands.test.ts @@ -1,17 +1,11 @@ import { describe, expect, it } from "vitest"; -import { parseSlashHelpSubcommand, parseSlashInvocation } from "../../src/feishu/slashCommands.js"; +import { parseSlashInvocation } from "../../src/feishu/slashCommands.js"; describe("slash command parser", () => { it("parses a slash invocation without resolving it", () => { - expect(parseSlashInvocation("/new")).toEqual({ name: "new", args: [] }); - expect(parseSlashInvocation("/review 看看这节")).toEqual({ name: "review", args: ["看看这节"] }); + expect(parseSlashInvocation("/project")).toEqual({ name: "project", args: [] }); + expect(parseSlashInvocation("/usage project")).toEqual({ name: "usage", args: ["project"] }); expect(parseSlashInvocation("写教案")).toBeNull(); }); - it("parses help subcommands only in the exact / help form", () => { - expect(parseSlashHelpSubcommand({ name: "new", args: ["help"] })).toBe("new"); - expect(parseSlashHelpSubcommand({ name: "unknown", args: ["help"] })).toBe("unknown"); - expect(parseSlashHelpSubcommand({ name: "new", args: ["help", "please"] })).toBeNull(); - expect(parseSlashHelpSubcommand({ name: "help", args: ["new"] })).toBeNull(); - }); }); diff --git a/hub/test/unit/trigger-queue.test.ts b/hub/test/unit/trigger-queue.test.ts index e54beda..136c5bc 100644 --- a/hub/test/unit/trigger-queue.test.ts +++ b/hub/test/unit/trigger-queue.test.ts @@ -117,6 +117,8 @@ function makeTrigger(prompt: string): Omit