feat: redesign Feishu project console

This commit is contained in:
2026-07-13 16:52:45 +08:00
parent 82f57317df
commit 69837bd50c
34 changed files with 1738 additions and 978 deletions
+4
View File
@@ -30,6 +30,10 @@
控制面与 Docker adapter 后置(见 ADR-0025)。 控制面与 Docker adapter 后置(见 ADR-0025)。
- Agent role 与 skill 是 Organization-scoped 动态运行配置:role 组合 model、system prompt、 - Agent role 与 skill 是 Organization-scoped 动态运行配置:role 组合 model、system prompt、
tools 与已安装 skillskill 版本进入 content-addressed 持久存储,run 只读加载所选快照。 tools 与已安装 skillskill 版本进入 content-addressed 持久存储,run 只读加载所选快照。
每个 Organization 必须且只能有一个启用中的默认 role;新建群绑定从该默认值初始化,之后
`ProjectGroupBinding` 持久化群内当前 role,run 在接纳时冻结该 role。飞书公开 slash 协议只含
`/project``/usage``/help`role、会话、目录操作走 `/project` 卡片,Claude 原生
`/compact` 只能由卡片动作以未经包装的精确 prompt 转发。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成 `settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 ADR-0018)。 运行时能力(见 ADR-0018)。
- 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号 - 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号
@@ -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 that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
`claudeSessionId` and pass it back to the next `query()` call 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 `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` tool surfaces can differ even when the underlying model is the same. A Feishu
and `/review` must not resume the same Claude runtime cursor by accident. 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 Role definitions are Organization-scoped runtime data. A role bundle selects
its default model, system prompt, tool allowlist and installed Agent skill 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; the next run cannot resume a provider context created under stale instructions;
label and ordering-only changes preserve conversational continuity. 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: Environment variables:
``` ```
ANTHROPIC_BASE_URL=https://openrouter.ai/api ANTHROPIC_BASE_URL=https://openrouter.ai/api
@@ -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;
@@ -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");
@@ -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();
@@ -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;
$$;
@@ -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();
+71 -61
View File
@@ -45,8 +45,9 @@ model Organization {
feishuApplicationConnection OrganizationFeishuApplicationConnection? feishuApplicationConnection OrganizationFeishuApplicationConnection?
agentSkills OrganizationAgentSkill[] agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[] agentRoles OrganizationAgentRole[]
auditEntries AuditEntry[] @relation("organizationAudit") projectGroupBindings ProjectGroupBinding[]
projectSearchDocuments ProjectSearchDocument[] auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[]
@@index([status]) @@index([status])
} }
@@ -116,12 +117,14 @@ model OrganizationAgentRole {
systemPrompt String? systemPrompt String?
tools Json? tools Json?
sortOrder Int @default(0) sortOrder Int @default(0)
isDefault Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
disabledAt DateTime? disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
skillBindings OrganizationAgentRoleSkill[] skillBindings OrganizationAgentRoleSkill[]
selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole")
@@unique([organizationId, roleId]) @@unique([organizationId, roleId])
@@unique([organizationId, id]) @@unique([organizationId, id])
@@ -132,10 +135,10 @@ model OrganizationAgentRole {
/// gives stable skill listing and prompt discovery order for a role bundle. /// gives stable skill listing and prompt discovery order for a role bundle.
model OrganizationAgentRoleSkill { model OrganizationAgentRoleSkill {
organizationId String organizationId String
agentRoleId String agentRoleId String
agentSkillId String agentSkillId String
sortOrder Int @default(0) sortOrder Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
role OrganizationAgentRole @relation(fields: [organizationId, agentRoleId], references: [organizationId, id], onDelete: Cascade) role OrganizationAgentRole @relation(fields: [organizationId, agentRoleId], references: [organizationId, id], onDelete: Cascade)
skill OrganizationAgentSkill @relation(fields: [organizationId, agentSkillId], references: [organizationId, id], onDelete: Cascade) skill OrganizationAgentSkill @relation(fields: [organizationId, agentSkillId], references: [organizationId, id], onDelete: Cascade)
@@ -169,16 +172,16 @@ model User {
platformRoles PlatformRoleAssignment[] platformRoles PlatformRoleAssignment[]
organizationMemberships OrganizationMembership[] organizationMemberships OrganizationMembership[]
createdProjects Project[] @relation("projectCreator") createdProjects Project[] @relation("projectCreator")
requestedRuns AgentRun[] @relation("runRequester") requestedRuns AgentRun[] @relation("runRequester")
heldLocks ProjectAgentLock[] @relation("lockHolder") heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator") feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
teamMemberships TeamMembership[] teamMemberships TeamMembership[]
externalPrincipalMemberships ExternalPrincipalMembership[] externalPrincipalMemberships ExternalPrincipalMembership[]
permissionGrants PermissionGrant[] @relation("grantCreator") permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator") roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor") auditEntries AuditEntry[] @relation("auditActor")
providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator") providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator")
feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator") feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator")
feishuIdentities FeishuUserIdentity[] feishuIdentities FeishuUserIdentity[]
} }
@@ -197,7 +200,7 @@ model OrganizationFeishuApplicationConnection {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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") secretVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersions")
activeSecretVersion FeishuApplicationCredentialVersion? @relation("activeFeishuCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict) activeSecretVersion FeishuApplicationCredentialVersion? @relation("activeFeishuCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict)
userIdentities FeishuUserIdentity[] userIdentities FeishuUserIdentity[]
@@ -229,19 +232,19 @@ model FeishuUserIdentity {
/// ADR-0024: all Feishu app material, including provider-local app/bot ids, is /// ADR-0024: all Feishu app material, including provider-local app/bot ids, is
/// inside one immutable authenticated envelope version. /// inside one immutable authenticated envelope version.
model FeishuApplicationCredentialVersion { model FeishuApplicationCredentialVersion {
id String @id @default(cuid()) id String @id @default(cuid())
connectionId String connectionId String
version Int version Int
envelopeVersion Int @default(1) envelopeVersion Int @default(1)
keyId String keyId String
envelope Json envelope Json
createdByUserId String? createdByUserId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
retiredAt DateTime? retiredAt DateTime?
connection OrganizationFeishuApplicationConnection @relation("feishuCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade) connection OrganizationFeishuApplicationConnection @relation("feishuCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade)
activeFor OrganizationFeishuApplicationConnection? @relation("activeFeishuCredentialVersion") 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]) @@unique([connectionId, version])
@@index([connectionId, retiredAt]) @@index([connectionId, retiredAt])
@@ -288,14 +291,14 @@ enum OrganizationConnectionStatus {
/// per immutable version. keyId/envelopeVersion are redacted rotation metadata; /// per immutable version. keyId/envelopeVersion are redacted rotation metadata;
/// all provider URL/token/API-key fields remain inside envelope ciphertext. /// all provider URL/token/API-key fields remain inside envelope ciphertext.
model ProviderCredentialVersion { model ProviderCredentialVersion {
id String @id @default(cuid()) id String @id @default(cuid())
connectionId String connectionId String
version Int version Int
envelopeVersion Int @default(1) envelopeVersion Int @default(1)
keyId String keyId String
envelope Json envelope Json
createdByUserId String? createdByUserId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
retiredAt DateTime? retiredAt DateTime?
connection OrganizationProviderConnection @relation("providerCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade) connection OrganizationProviderConnection @relation("providerCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade)
@@ -444,14 +447,14 @@ enum FolderKind {
} }
model Folder { model Folder {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
parentId String? parentId String?
name String name String
kind FolderKind @default(REGULAR) kind FolderKind @default(REGULAR)
sortKey String @default("") sortKey String @default("")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
archivedAt DateTime? archivedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@ -475,18 +478,19 @@ model Project {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
archivedAt DateTime? archivedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
groupBindings ProjectGroupBinding[] groupBindings ProjectGroupBinding[]
agentSessions AgentSession[] agentSessions AgentSession[]
agentRuns AgentRun[] agentRuns AgentRun[]
agentLock ProjectAgentLock? agentLock ProjectAgentLock?
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants") roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit") auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges") fileChanges AgentFileChange[] @relation("projectFileChanges")
searchDocument ProjectSearchDocument? searchDocument ProjectSearchDocument?
@@unique([organizationId, id])
@@index([organizationId, archivedAt]) @@index([organizationId, archivedAt])
@@index([folderId, archivedAt]) @@index([folderId, archivedAt])
@@index([archivedAt]) @@index([archivedAt])
@@ -496,16 +500,16 @@ model Project {
/// triggers keep it synchronized with Project and Folder mutations; Project /// triggers keep it synchronized with Project and Folder mutations; Project
/// remains the source of truth and authorization remains outside this table. /// remains the source of truth and authorization remains outside this table.
model ProjectSearchDocument { model ProjectSearchDocument {
projectId String @id projectId String @id
organizationId String organizationId String
name String name String
code String? code String?
normalizedCode String normalizedCode String
normalizedName String normalizedName String
breadcrumb String breadcrumb String
normalizedBreadcrumb String normalizedBreadcrumb String
normalizedSearchText String normalizedSearchText String
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], 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 /// (1:1). Historical archived bindings are retained for audit; partial unique
/// indexes in migrations enforce one active binding per project and per chat. /// indexes in migrations enforce one active binding per project and per chat.
model ProjectGroupBinding { model ProjectGroupBinding {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String organizationId String
chatId String projectId String
createdByUserId String? chatId String
createdAt DateTime @default(now()) createdByUserId String?
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
archivedAt DateTime? updatedAt DateTime @updatedAt
archivedAt DateTime?
selectedAgentRoleId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) 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([projectId, archivedAt])
@@index([chatId, archivedAt]) @@index([chatId, archivedAt])
@@index([selectedAgentRoleId])
} }
// --- AgentRun, session, lock (ADR-0002, 0017) ----------------------------- // --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
@@ -727,17 +737,17 @@ model RoleTriggerGrant {
/// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type, /// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type,
/// actor, timestamp, details are OPEN. This table mirrors that: `runId` is the /// actor, timestamp, details are OPEN. This table mirrors that: `runId` is the
model AuditEntry { model AuditEntry {
id String @id @default(cuid()) id String @id @default(cuid())
runId String? runId String?
projectId String? projectId String?
organizationId String? organizationId String?
actorUserId String? actorUserId String?
action String action String
metadata Json metadata Json
createdAt DateTime @default(now()) createdAt DateTime @default(now())
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull) actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull) project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull)
organization Organization? @relation("organizationAudit", fields: [organizationId], references: [id], onDelete: SetNull) organization Organization? @relation("organizationAudit", fields: [organizationId], references: [id], onDelete: SetNull)
@@index([runId]) @@index([runId])
+41 -4
View File
@@ -89,6 +89,7 @@ export class OrganizationAgentConfiguration {
readonly systemPrompt?: string | null | undefined; readonly systemPrompt?: string | null | undefined;
readonly tools?: readonly string[] | null | undefined; readonly tools?: readonly string[] | null | undefined;
readonly sortOrder?: number | undefined; readonly sortOrder?: number | undefined;
readonly isDefault?: boolean | undefined;
}): Promise<{ readonly id: string; readonly roleId: string }> { }): Promise<{ readonly id: string; readonly roleId: string }> {
await this.requireActiveOrganization(input.organizationId); await this.requireActiveOrganization(input.organizationId);
if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`); 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) => { return this.prisma.$transaction(async (tx) => {
const previous = await tx.organizationAgentRole.findUnique({ const previous = await tx.organizationAgentRole.findUnique({
where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } }, 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({ const role = await tx.organizationAgentRole.upsert({
where: { where: {
organizationId_roleId: { organizationId_roleId: {
@@ -124,6 +139,7 @@ export class OrganizationAgentConfiguration {
systemPrompt: normalizeOptionalText(input.systemPrompt), systemPrompt: normalizeOptionalText(input.systemPrompt),
tools: createTools, tools: createTools,
sortOrder, sortOrder,
isDefault: effectiveIsDefault,
}, },
update: { update: {
label, label,
@@ -131,6 +147,7 @@ export class OrganizationAgentConfiguration {
...(input.systemPrompt !== undefined ? { systemPrompt: normalizeOptionalText(input.systemPrompt) } : {}), ...(input.systemPrompt !== undefined ? { systemPrompt: normalizeOptionalText(input.systemPrompt) } : {}),
...(updateTools !== undefined ? { tools: updateTools } : {}), ...(updateTools !== undefined ? { tools: updateTools } : {}),
sortOrder, sortOrder,
isDefault: effectiveIsDefault,
disabledAt: null, disabledAt: null,
}, },
select: { id: true, roleId: true }, select: { id: true, roleId: true },
@@ -140,6 +157,12 @@ export class OrganizationAgentConfiguration {
(input.systemPrompt !== undefined && normalizeOptionalText(input.systemPrompt) !== previous.systemPrompt) || (input.systemPrompt !== undefined && normalizeOptionalText(input.systemPrompt) !== previous.systemPrompt) ||
(input.tools !== undefined && JSON.stringify(input.tools) !== JSON.stringify(previous.tools)) (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]); if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
await tx.auditEntry.create({ await tx.auditEntry.create({
data: { data: {
@@ -154,6 +177,8 @@ export class OrganizationAgentConfiguration {
: normalizeOptionalText(input.systemPrompt) !== null, : normalizeOptionalText(input.systemPrompt) !== null,
tools: input.tools === undefined ? "unchanged" : input.tools === null ? "all" : [...input.tools], tools: input.tools === undefined ? "unchanged" : input.tools === null ? "all" : [...input.tools],
sortOrder, sortOrder,
isDefault: effectiveIsDefault,
defaultExplicit: input.isDefault !== undefined,
}, },
}, },
}); });
@@ -237,14 +262,26 @@ async function archiveRoleSessions(
roleIds: readonly string[], roleIds: readonly string[],
): Promise<void> { ): Promise<void> {
if (roleIds.length === 0) return; if (roleIds.length === 0) return;
await tx.agentSession.updateMany({ const sessions = await tx.agentSession.findMany({
where: { where: {
roleId: { in: [...new Set(roleIds)] }, roleId: { in: [...new Set(roleIds)] },
archivedAt: null,
project: { organizationId }, 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 { function nonEmpty(value: string, label: string): string {
+2 -2
View File
@@ -12,8 +12,8 @@
* A named role preset — the full per-run agent bundle. Roles are **data**, not * 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 * 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 * product config, not a spec invariant). `roleId` is an opaque string and
* doubles as the slash-command name (`/draft ...`) — the registry holds the * is selected through the project console — the registry holds the role set,
* role set, so new roles are added by configuration, not by editing code. * so new roles are added by configuration, not by editing code.
* *
* A role bundles everything that distinguishes one agent persona from another: * A role bundles everything that distinguishes one agent persona from another:
* model, system prompt, and the tool surface (files / cph / feishu / skills / * model, system prompt, and the tool surface (files / cph / feishu / skills /
+3
View File
@@ -293,6 +293,9 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
case "result": { case "result": {
const result = message as SDKResultMessage; const result = message as SDKResultMessage;
sdkSessionId = result.session_id; 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; costUsd = Number.isFinite(result.total_cost_usd) ? result.total_cost_usd : undefined;
if (result.subtype !== "success") { if (result.subtype !== "success") {
error = `result_${result.subtype}`; error = `result_${result.subtype}`;
+9 -1
View File
@@ -47,6 +47,7 @@ async function main(argv: readonly string[]): Promise<void> {
: {}), : {}),
...(tools !== undefined ? { tools } : {}), ...(tools !== undefined ? { tools } : {}),
...(sortOrderRaw !== undefined ? { sortOrder: integer(sortOrderRaw, "sort-order") } : {}), ...(sortOrderRaw !== undefined ? { sortOrder: integer(sortOrderRaw, "sort-order") } : {}),
...(options.has("default") ? { isDefault: boolean(options.get("default")!, "default") } : {}),
}); });
console.log(JSON.stringify(role)); console.log(JSON.stringify(role));
return; return;
@@ -79,6 +80,7 @@ async function main(argv: readonly string[]): Promise<void> {
systemPromptConfigured: role.systemPrompt !== null, systemPromptConfigured: role.systemPrompt !== null,
tools: role.tools, tools: role.tools,
disabled: role.disabledAt !== null, disabled: role.disabledAt !== null,
default: role.isDefault,
skills: role.skillBindings.map((binding) => ({ skills: role.skillBindings.map((binding) => ({
name: binding.skill.name, name: binding.skill.name,
version: binding.skill.version, version: binding.skill.version,
@@ -138,10 +140,16 @@ function integer(raw: string, name: string): number {
return value; 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 { function printHelp(): void {
console.log(`Usage: console.log(`Usage:
agent-config install-skill --organization ORG --source DIR --version VERSION 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 set-role-skills --organization ORG --role ID --skills name,name
agent-config list --organization ORG agent-config list --organization ORG
agent-config verify-store --organization ORG`); agent-config verify-store --organization ORG`);
+1
View File
@@ -233,6 +233,7 @@ async function initializeSilo(
roleId: "draft", roleId: "draft",
label: "草稿", label: "草稿",
sortOrder: 10, sortOrder: 10,
isDefault: true,
}, },
{ {
organizationId: input.organization.id, organizationId: input.organization.id,
+6 -4
View File
@@ -47,8 +47,8 @@ export class MessageBatcher {
this.extendedDebounceMs = options.extendedDebounceMs ?? DEFAULT_OPTIONS.extendedDebounceMs; this.extendedDebounceMs = options.extendedDebounceMs ?? DEFAULT_OPTIONS.extendedDebounceMs;
} }
async enqueue(chatId: string, senderOpenId: string, text: string): Promise<void> { async enqueue(chatId: string, senderOpenId: string, text: string, discriminator?: string): Promise<void> {
const key = messageBatchKey(chatId, senderOpenId); const key = messageBatchKey(chatId, senderOpenId, discriminator);
await this.runSerial(key, async () => { await this.runSerial(key, async () => {
const existing = this.pending.get(key); const existing = this.pending.get(key);
const batch = const batch =
@@ -120,6 +120,8 @@ export class MessageBatcher {
} }
} }
export function messageBatchKey(chatId: string, senderOpenId: string): string { export function messageBatchKey(chatId: string, senderOpenId: string, discriminator?: string): string {
return `${chatId}:${senderOpenId}`; return discriminator === undefined
? `${chatId}:${senderOpenId}`
: `${chatId}:${senderOpenId}:${discriminator}`;
} }
+322
View File
@@ -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<FolderDestinationPage> {
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<ProjectConsoleState> {
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<string, unknown>)["claudeSessionId"];
return typeof value === "string" && value !== "";
}
function isUserResumable(metadata: unknown): boolean {
return typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) &&
(metadata as Record<string, unknown>)["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<void> {
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<boolean> {
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<readonly { readonly id: string; readonly title: string | null; readonly updatedAt: Date; readonly runCount: number }[]> {
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<void> {
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<string | null> {
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<string> {
const rows = await prisma.$queryRaw<Array<{ breadcrumb: string }>>(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;
}
+264 -36
View File
@@ -5,12 +5,27 @@ export type ProjectOnboardingView =
| { readonly mode: "browse"; readonly result: ProjectFolderPage }; | { readonly mode: "browse"; readonly result: ProjectFolderPage };
export interface ProjectOnboardingActionValue { 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 organization_id: string;
readonly project_id?: string | undefined; readonly project_id?: string | undefined;
readonly folder_id?: string | undefined; readonly folder_id?: string | undefined;
readonly search_query?: string | undefined; readonly search_query?: string | undefined;
readonly page?: number | undefined; readonly page?: number | undefined;
readonly agent_role_id?: string | undefined;
readonly session_id?: string | undefined;
} }
export function buildUnboundChatOnboardingCard(params: { export function buildUnboundChatOnboardingCard(params: {
@@ -62,49 +77,165 @@ export function buildProjectManagementCard(params: {
readonly projectId: string; readonly projectId: string;
readonly projectName: string; readonly projectName: string;
readonly title?: string | undefined; 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<string, unknown> { }): Record<string, unknown> {
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 { return {
config: { wide_screen_mode: true }, config: { wide_screen_mode: true },
header: { header: {
title: { tag: "plain_text", content: params.title ?? "项目管理" }, title: { tag: "plain_text", content: params.title ?? "项目管理" },
template: "green", template: "green",
}, },
elements: [ elements,
{ tag: "markdown", content: `当前项目: **${escapeMarkdown(params.projectName)}**` }, };
}
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<string, unknown> {
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", tag: "markdown",
name: "project_rename_form", content: `**${escapeMarkdown(session.title ?? "未命名会话")}**\n${session.runCount} runs · ${formatDate(session.updatedAt)}`,
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: "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<string, unknown> {
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 folderId = fields.folder_id;
const searchQuery = fields.search_query; const searchQuery = fields.search_query;
const page = fields.page; 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 (!isAction(rawAction) || typeof organizationId !== "string" || organizationId === "") return null;
if ((rawAction === "bind_project" || rawAction === "rename_project") && (typeof projectId !== "string" || projectId === "")) 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 (rawAction === "search_page" && (typeof searchQuery !== "string" || !validPage(page))) return null;
if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) return null; if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) return null;
if (page !== undefined && !validPage(page)) 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 { return {
action: rawAction, action: rawAction,
organization_id: organizationId, organization_id: organizationId,
@@ -132,6 +267,8 @@ export function projectOnboardingActionFromValue(value: unknown): ProjectOnboard
...(typeof folderId === "string" && folderId !== "" ? { folder_id: folderId } : {}), ...(typeof folderId === "string" && folderId !== "" ? { folder_id: folderId } : {}),
...(typeof searchQuery === "string" ? { search_query: searchQuery.slice(0, 100) } : {}), ...(typeof searchQuery === "string" ? { search_query: searchQuery.slice(0, 100) } : {}),
...(typeof page === "number" ? { page } : {}), ...(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( function actionButton(
label: string, label: string,
value: ProjectOnboardingActionValue, value: ProjectOnboardingActionValue,
@@ -273,7 +497,11 @@ function actionButton(
function isAction(value: unknown): value is ProjectOnboardingActionValue["action"] { function isAction(value: unknown): value is ProjectOnboardingActionValue["action"] {
return value === "create_project_from_chat" || value === "bind_project" 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 { function validPage(value: unknown): value is number {
+80 -336
View File
@@ -1,11 +1,6 @@
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js"; 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 { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import type { TriggerQueue } from "./triggerQueue.js";
export interface SlashInvocation { export interface SlashInvocation {
readonly name: string; readonly name: string;
@@ -21,20 +16,13 @@ export interface SlashCommandRunContext {
} }
export interface SlashCommandDefinition { export interface SlashCommandDefinition {
readonly name: string; readonly name: "help" | "project" | "usage";
readonly usage: string; readonly usage: string;
readonly summary: string; readonly summary: string;
readonly details: readonly string[]; readonly details: readonly string[];
run(context: SlashCommandRunContext): Promise<void>; run(context: SlashCommandRunContext): Promise<void>;
} }
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; const TERMINAL_RUN_STATUSES = ["COMPLETED", "FAILED", "TIMED_OUT", "CANCELED"] as const;
export function parseSlashInvocation(prompt: string): SlashInvocation | null { export function parseSlashInvocation(prompt: string): SlashInvocation | null {
@@ -43,179 +31,94 @@ export function parseSlashInvocation(prompt: string): SlashInvocation | null {
const tokens = trimmed.split(/\s+/); const tokens = trimmed.split(/\s+/);
const rawCommand = tokens[0]; const rawCommand = tokens[0];
if (rawCommand === undefined || rawCommand.length <= 1) return null; if (rawCommand === undefined || rawCommand.length <= 1) return null;
return { return { name: rawCommand.slice(1), args: tokens.slice(1) };
name: rawCommand.slice(1),
args: tokens.slice(1),
};
} }
export function parseSlashHelpSubcommand(invocation: SlashInvocation): string | null { export function createSlashCommandRegistry(
if (invocation.name === "help") return null; deps: { readonly prisma: PrismaClient },
return invocation.args.length === 1 && invocation.args[0] === "help" ): ReadonlyMap<string, SlashCommandDefinition> {
? invocation.name
: null;
}
export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): ReadonlyMap<string, SlashCommandDefinition> {
const commands = new Map<string, SlashCommandDefinition>(); const commands = new Map<string, SlashCommandDefinition>();
const add = (command: SlashCommandDefinition): void => { const add = (command: SlashCommandDefinition): void => {
if (commands.has(command.name)) { if (commands.has(command.name)) throw new Error(`duplicate slash command definition: /${command.name}`);
throw new Error(`duplicate slash command definition: /${command.name}`);
}
commands.set(command.name, command); commands.set(command.name, command);
}; };
add({ add({
name: "help", name: "help",
usage: "/help [command]", usage: "/help [project|usage]",
summary: "查看可用 slash 命令或单个命令说明。", summary: "查看 Hub 命令。",
details: [ details: ["未知 slash 命令会明确报错,不会发送给 Agent。"],
"不创建 agent run,也不改变当前会话。", run: async ({ invocation, chatId, rt, sendOptions }) => {
"支持 /help new 和 /new help 两种写法。", if (invocation.args.length > 1) {
], await sendText(rt, chatId, "用法: /help [project|usage]", sendOptions);
run: async ({ invocation, projectId, chatId, rt, sendOptions }) => { return;
const registry = await deps.settings.modelRegistry({ projectId }); }
await sendText(rt, chatId, formatHelpCommandInvocation(invocation, registry, commands), sendOptions); const target = invocation.args[0];
}, await sendText(rt, chatId, target === undefined
}); ? formatSlashOverview(commands)
: formatSlashHelpTarget(target, 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);
}, },
}); });
add({ add({
name: "project", name: "project",
usage: "/project", usage: "/project",
summary: "打开当前项目管理卡片,可修改项目名称。", summary: "打开当前项目控制台,切换角色、管理会话和项目。",
details: [ details: ["不同区域按当前操作者的项目与组织权限显示。"],
"要求操作者拥有当前项目的 MANAGE 权限。",
"实际卡片由飞书 trigger adapter 渲染,不创建 agent run。",
],
run: async ({ chatId, rt, sendOptions }) => { run: async ({ chatId, rt, sendOptions }) => {
await sendText(rt, chatId, "请在飞书项目群中使用 @bot /project 打开项目管理卡片。", sendOptions); await sendText(rt, chatId, "请在绑定的项目群中使用 /project。", sendOptions);
}, },
}); });
add({ add({
name: "resume", name: "usage",
usage: "/resume", usage: "/usage [current|project]",
summary: "恢复最近一次已归档的会话。", summary: "查看 Hub 记录的真实 Agent 用量与成本。",
details: [ details: ["current 只统计当前角色的活跃会话;project 统计整个项目。"],
"只恢复当前项目最近归档的 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 会单独列出。",
],
run: async ({ invocation, projectId, chatId, rt, sendOptions }) => { run: async ({ invocation, projectId, chatId, rt, sendOptions }) => {
if (invocation.args.length > 0) { const scope = invocation.args[0] ?? "current";
await sendText(rt, chatId, ["用法错误: /cost 暂不接受参数。", "", formatBuiltinSlashCommandHelp(commands.get("cost")!)].join("\n"), sendOptions); if (invocation.args.length > 1 || (scope !== "current" && scope !== "project")) {
await sendText(rt, chatId, "用法: /usage [current|project]", sendOptions);
return; return;
} }
const sessionIds = scope === "project"
const sessions = await deps.prisma.agentSession.findMany({ ? undefined
where: { projectId, archivedAt: null }, : await currentRoleSessionIds(deps.prisma, projectId, chatId);
orderBy: { updatedAt: "asc" },
select: { id: true },
});
if (sessions.length === 0) {
await sendText(rt, chatId, "当前会话还没有 agent session。", sendOptions);
return;
}
const runs = await deps.prisma.agentRun.findMany({ const runs = await deps.prisma.agentRun.findMany({
where: { where: {
projectId, projectId,
sessionId: { in: sessions.map((session) => session.id) }, ...(sessionIds === undefined ? {} : { sessionId: { in: sessionIds } }),
status: { in: [...TERMINAL_RUN_STATUSES] }, status: { in: [...TERMINAL_RUN_STATUSES] },
finishedAt: { not: null }, finishedAt: { not: null },
}, },
orderBy: { finishedAt: "asc" }, orderBy: { finishedAt: "asc" },
select: { select: { model: true, provider: true, inputTokens: true, outputTokens: true, costUsd: true },
model: true,
provider: true,
inputTokens: true,
outputTokens: true,
costUsd: true,
},
}); });
await sendText(rt, chatId, formatUsageReport(runs, scope), sendOptions);
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);
}, },
}); });
return commands; return commands;
} }
interface CostReportRun { async function currentRoleSessionIds(
prisma: PrismaClient,
projectId: string,
chatId: string,
): Promise<string[]> {
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 model: string;
readonly provider: string; readonly provider: string;
readonly inputTokens: number | null; readonly inputTokens: number | null;
@@ -223,225 +126,66 @@ interface CostReportRun {
readonly costUsd: unknown; readonly costUsd: unknown;
} }
interface CostReportBucket { function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "project"): string {
readonly provider: string; if (runs.length === 0) return `${scope === "current" ? "当前角色会话" : "当前项目"}还没有已结束的 Agent run。`;
readonly model: string; const buckets = new Map<string, {
runs: number; provider: string; model: string; runs: number; inputTokens: number; outputTokens: number; costUsd: number;
inputTokens: number; }>();
outputTokens: number;
costUsd: number;
}
function formatCostReport(runs: readonly CostReportRun[]): string {
if (runs.length === 0) {
return "当前会话还没有已结束的 agent run。";
}
const buckets = new Map<string, CostReportBucket>();
let recordedRuns = 0; let recordedRuns = 0;
let unrecordedRuns = 0; let unrecordedRuns = 0;
let totalInputTokens = 0; let totalInputTokens = 0;
let totalOutputTokens = 0; let totalOutputTokens = 0;
let totalCostUsd = 0; let totalCostUsd = 0;
for (const run of runs) { for (const run of runs) {
const costUsd = decimalToNumberOrNull(run.costUsd); const costUsd = decimalToNumberOrNull(run.costUsd);
if (costUsd === null) { if (costUsd === null) { unrecordedRuns++; continue; }
unrecordedRuns++;
continue;
}
recordedRuns++; recordedRuns++;
const inputTokens = run.inputTokens ?? 0;
const outputTokens = run.outputTokens ?? 0;
totalInputTokens += inputTokens;
totalOutputTokens += outputTokens;
totalCostUsd += costUsd;
const key = `${run.provider}\u0000${run.model}`; const key = `${run.provider}\u0000${run.model}`;
let bucket = buckets.get(key); const bucket = buckets.get(key) ?? {
if (bucket === undefined) { provider: run.provider, model: run.model, runs: 0, inputTokens: 0, outputTokens: 0, costUsd: 0,
bucket = { };
provider: run.provider,
model: run.model,
runs: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: 0,
};
buckets.set(key, bucket);
}
bucket.runs++; bucket.runs++;
bucket.inputTokens += inputTokens; bucket.inputTokens += run.inputTokens ?? 0;
bucket.outputTokens += outputTokens; bucket.outputTokens += run.outputTokens ?? 0;
bucket.costUsd += costUsd; 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 = [ const lines = [
"当前会话已记录 agent 成本:", `${scope === "current" ? "当前角色会话" : "当前项目"}用量`,
`总计: ${formatUsd(totalCostUsd)}`, `已记录成本: ${formatInteger(recordedRuns)} runs · ${formatUsd(totalCostUsd)}`,
`Runs: ${formatInteger(recordedRuns)} 已记录${unrecordedRuns > 0 ? ` / ${formatInteger(unrecordedRuns)} 未记录` : ""}`,
`Tokens: input ${formatInteger(totalInputTokens)} / output ${formatInteger(totalOutputTokens)}`, `Tokens: input ${formatInteger(totalInputTokens)} / output ${formatInteger(totalOutputTokens)}`,
"",
"按模型:",
]; ];
if (unrecordedRuns > 0) lines.push(`另有 ${formatInteger(unrecordedRuns)} 个 run 未记录成本。`);
const sortedBuckets = [...buckets.values()].sort((a, b) => b.costUsd - a.costUsd); for (const bucket of buckets.values()) {
for (const bucket of sortedBuckets) { lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}`);
lines.push(
`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}, input ${formatInteger(bucket.inputTokens)} / output ${formatInteger(bucket.outputTokens)}`,
);
} }
return lines.join("\n"); return lines.join("\n");
} }
function formatHelpCommandInvocation(
invocation: SlashInvocation,
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): 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( export function formatSlashHelpTarget(
target: string, target: string,
registry: ModelRegistry, commands: ReadonlyMap<string, SlashCommandDefinition>,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string { ): string {
const normalizedTarget = normalizeHelpTarget(target); const normalized = target.trim().replace(/^\/+/, "");
if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands); const command = commands.get(normalized);
return formatSlashCommandHelp(normalizedTarget, registry, slashCommands) ?? formatUnknownSlashHelp(normalizedTarget, registry, slashCommands); if (command === undefined) return [`未知 slash 命令 /${normalized}`, "", formatSlashOverview(commands)].join("\n");
}
function normalizeHelpTarget(target: string): string {
return target.trim().replace(/^\/+/, "");
}
function formatSlashOverview(
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): 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, SlashCommandDefinition>,
): 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`;
return [ return [
`/${command.name}`, `/${command.name}`,
command.summary, command.summary,
"", "",
`用法: ${command.usage}`, `用法: ${command.usage}`,
...command.details.map((detail) => `- ${detail}`), ...command.details.map((detail) => `- ${detail}`),
"",
helpUsage,
].join("\n"); ].join("\n");
} }
function formatRoleSlashCommandHelp(role: RoleEntry): string { function formatSlashOverview(commands: ReadonlyMap<string, SlashCommandDefinition>): 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, SlashCommandDefinition>,
): string {
const normalizedName = normalizeHelpTarget(commandName);
return [ return [
`未知 slash 命令 /${normalizedName}`, "可用 slash 命令:",
...[...commands.values()].map((command) => `/${command.name} - ${command.summary}`),
"", "",
formatSlashOverview(registry, slashCommands), "普通消息会使用 /project 中选定的当前角色。",
].join("\n"); ].join("\n");
} }
function visibleRoleCommands(
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): readonly RoleEntry[] {
return registry
.listRoles()
.filter((role) => !slashCommands.has(role.id));
}
+281 -108
View File
@@ -52,22 +52,34 @@ import {
type StagedMessageResourceBatch, type StagedMessageResourceBatch,
} from "./resourceStaging.js"; } from "./resourceStaging.js";
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.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 { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
import { import {
bindFeishuChatToProject, bindFeishuChatToProject,
createFolder,
createProjectFromFeishuChat, createProjectFromFeishuChat,
ensureOrganizationProjectSettings, ensureOrganizationProjectSettings,
moveProjectToFolder,
renameProjectForActor, renameProjectForActor,
} from "../projectOnboarding.js"; } from "../projectOnboarding.js";
import { import {
buildProjectManagementCard, buildProjectManagementCard,
buildMoveProjectCard,
buildSessionHistoryCard,
buildProjectOnboardingResolvedCard, buildProjectOnboardingResolvedCard,
buildUnboundChatOnboardingCard, buildUnboundChatOnboardingCard,
projectOnboardingActionFromValue, projectOnboardingActionFromValue,
type ProjectOnboardingActionValue, type ProjectOnboardingActionValue,
type ProjectOnboardingView, type ProjectOnboardingView,
} from "./projectOnboardingCard.js"; } from "./projectOnboardingCard.js";
import {
archiveCurrentRoleSession,
browseFolderDestinations,
listRoleSessionHistory,
loadProjectConsole,
resumeRoleSession,
selectProjectGroupRole,
} from "./projectConsole.js";
import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js"; import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js";
import { browseBindableFolder, discoverBindableProjects } from "../projectDiscovery.js"; import { browseBindableFolder, discoverBindableProjects } from "../projectDiscovery.js";
@@ -113,6 +125,7 @@ interface TriggerRunContext {
readonly projectId: string; readonly projectId: string;
readonly senderOpenId: string; readonly senderOpenId: string;
readonly actor: TriggerActor; readonly actor: TriggerActor;
readonly roleId: string;
} }
type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped"; type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped";
@@ -150,9 +163,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
: new SiloFixedWindowRateLimiter(deps.maxFeishuEventsPerMinute, 60_000); : new SiloFixedWindowRateLimiter(deps.maxFeishuEventsPerMinute, 60_000);
const slashCommands = createSlashCommandRegistry({ const slashCommands = createSlashCommandRegistry({
prisma: deps.prisma, prisma: deps.prisma,
settings: deps.settings,
logger: deps.logger,
triggerQueue,
}); });
const batchContexts = new Map<string, TriggerRunContext>(); const batchContexts = new Map<string, TriggerRunContext>();
// runId → AbortController for live runs. Registered when a run starts, // runId → AbortController for live runs. Registered when a run starts,
@@ -178,10 +188,48 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} }
}, deps.messageBatcherOptions); }, deps.messageBatcherOptions);
async function projectConsoleCard(
projectId: string,
chatId: string,
actorOpenId: string,
title?: string,
): Promise<Record<string, unknown>> {
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( async function startAgentRun(
context: TriggerRunContext, context: TriggerRunContext,
cleanPrompt: string, cleanPrompt: string,
options: { readonly queueIfLocked?: boolean } = {}, options: { readonly queueIfLocked?: boolean; readonly nativeSlash?: boolean } = {},
): Promise<StartAgentRunOutcome> { ): Promise<StartAgentRunOutcome> {
const { msg, rt, chatId, projectId, senderOpenId, actor } = context; const { msg, rt, chatId, projectId, senderOpenId, actor } = context;
const sendOptions = sendOptionsForTriggerMessage(msg); const sendOptions = sendOptionsForTriggerMessage(msg);
@@ -195,7 +243,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return "rejected"; return "rejected";
} }
if (!queueIfLocked) return "locked"; 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({ const project = await deps.prisma.project.findUnique({
@@ -207,15 +255,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return "skipped"; return "skipped";
} }
// ADR-0017: role-as-data. Parse a leading `/<role>` 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 models = await deps.settings.modelRegistry({ projectId });
const { roleId: parsedRole, prompt: parsedAgentPrompt } = extractRole(cleanPrompt, models); const roleId = context.roleId;
const roleId = parsedRole ?? "draft";
// ADR-0019: role trigger is the second gate after project agent.trigger. // ADR-0019: role trigger is the second gate after project agent.trigger.
// Unconfigured roles remain open for back-compat; configured roles require // Configured roles require a matching active RoleTriggerGrant for any
// a matching active RoleTriggerGrant for any resolved principal. // resolved principal; otherwise the role remains open within the project.
const roleDecision = await authorizer.can({ const roleDecision = await authorizer.can({
actor, actor,
action: "role.trigger", action: "role.trigger",
@@ -262,8 +306,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
projectWorkspaceRoot, projectWorkspaceRoot,
deps.resourceLimits, deps.resourceLimits,
); );
const agentPrompt = appendStagedResourcePaths(parsedAgentPrompt, stagedResources, project.workspaceDir); const agentPrompt = appendStagedResourcePaths(cleanPrompt, stagedResources, project.workspaceDir);
const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext); const promptForAgent = options.nativeSlash === true
? cleanPrompt
: withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
// Linearization point for org lifecycle + session/run/lock admission. // Linearization point for org lifecycle + session/run/lock admission.
// FOR SHARE on the Organization row conflicts with a concurrent status // FOR SHARE on the Organization row conflicts with a concurrent status
@@ -623,10 +669,16 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return "started"; return "started";
} }
async function enqueueLockedTrigger(context: TriggerRunContext, cleanPrompt: string): Promise<StartAgentRunOutcome> { async function enqueueLockedTrigger(
context: TriggerRunContext,
cleanPrompt: string,
executionKind: QueuedTrigger["executionKind"] = "conversation",
): Promise<StartAgentRunOutcome> {
const position = triggerQueue.enqueue(context.projectId, { const position = triggerQueue.enqueue(context.projectId, {
chatId: context.chatId, chatId: context.chatId,
prompt: cleanPrompt, prompt: cleanPrompt,
roleId: context.roleId,
executionKind,
msg: context.msg, msg: context.msg,
senderOpenId: context.senderOpenId, senderOpenId: context.senderOpenId,
actor: context.actor, actor: context.actor,
@@ -664,7 +716,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const next = triggerQueue.dequeue(projectId); const next = triggerQueue.dequeue(projectId);
if (next === null) return; 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 === "started") return;
if (outcome === "locked") { if (outcome === "locked") {
requeueTrigger(next); requeueTrigger(next);
@@ -681,6 +736,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
projectId: trigger.projectId, projectId: trigger.projectId,
senderOpenId: trigger.senderOpenId, senderOpenId: trigger.senderOpenId,
actor: trigger.actor, actor: trigger.actor,
roleId: trigger.roleId,
}; };
} }
@@ -688,6 +744,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const position = triggerQueue.enqueue(trigger.projectId, { const position = triggerQueue.enqueue(trigger.projectId, {
chatId: trigger.chatId, chatId: trigger.chatId,
prompt: trigger.prompt, prompt: trigger.prompt,
roleId: trigger.roleId,
executionKind: trigger.executionKind,
msg: trigger.msg, msg: trigger.msg,
senderOpenId: trigger.senderOpenId, senderOpenId: trigger.senderOpenId,
actor: trigger.actor, actor: trigger.actor,
@@ -761,6 +819,169 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (organization.organizationId !== action.organization_id) { if (organization.organizationId !== action.organization_id) {
throw new Error("project onboarding organization mismatch"); 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") { if (action.action === "rename_project") {
const projectId = action.project_id; const projectId = action.project_id;
if (projectId === undefined) throw new Error("rename_project action requires 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), name: submittedName.slice(0, 100),
}); });
if (messageId === undefined) throw new Error("project rename requires message id"); if (messageId === undefined) throw new Error("project rename requires message id");
await patchCard(rt, messageId, buildProjectManagementCard({ await patchCard(rt, messageId, await projectConsoleCard(
organizationId: organization.organizationId, renamed.projectId,
projectId: renamed.projectId, chatId,
projectName: renamed.name, operatorOpenId,
title: "项目名称已更新", "项目名称已更新",
})); ));
deps.logger.info({ chatId, projectId, operatorOpenId }, "project onboarding: project renamed"); deps.logger.info({ chatId, projectId, operatorOpenId }, "project onboarding: project renamed");
return; return;
} }
@@ -834,12 +1055,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
folderId: action.folder_id, folderId: action.folder_id,
}); });
if (messageId !== undefined) { if (messageId !== undefined) {
await patchCard(rt, messageId, buildProjectManagementCard({ await patchCard(rt, messageId, await projectConsoleCard(
organizationId: organization.organizationId, project.projectId,
projectId: project.projectId, chatId,
projectName: name, operatorOpenId,
title: "已创建并绑定项目", "已创建并绑定项目",
})); ));
} }
deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: created project from chat"); deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: created project from chat");
return; return;
@@ -863,16 +1084,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
chatId, chatId,
}); });
if (messageId !== undefined) { if (messageId !== undefined) {
const boundProject = await deps.prisma.project.findUniqueOrThrow({ await patchCard(rt, messageId, await projectConsoleCard(
where: { id: project.projectId }, project.projectId,
select: { name: true }, chatId,
}); operatorOpenId,
await patchCard(rt, messageId, buildProjectManagementCard({ "已绑定项目",
organizationId: organization.organizationId, ));
projectId: project.projectId,
projectName: boundProject.name,
title: "已绑定项目",
}));
} }
deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: bound existing project"); deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: bound existing project");
} catch (e) { } catch (e) {
@@ -905,7 +1122,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} }
const binding = await deps.prisma.projectGroupBinding.findFirst({ const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null }, where: { chatId, archivedAt: null },
select: { projectId: true }, select: { projectId: true, selectedRole: { select: { roleId: true } } },
}); });
if (binding === null) { if (binding === null) {
deps.logger.debug({ chatId }, "feishu interrupt: chat not bound to any project"); 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. // ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group.
const binding = await deps.prisma.projectGroupBinding.findFirst({ const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null }, where: { chatId, archivedAt: null },
select: { projectId: true }, select: { projectId: true, selectedRole: { select: { roleId: true } } },
}); });
if (binding === null) { if (binding === null) {
deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project"); 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; 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 // Hub owns a closed slash-command protocol. Unknown commands fail visibly;
// batcher. Session commands bypass the lock because they don't create runs; // they are never downgraded to Agent text or forwarded to the SDK.
// role/unknown slash prompts still use the normal run path and queue if locked.
if (cleanPrompt.startsWith("/")) { if (cleanPrompt.startsWith("/")) {
const invocation = parseSlashInvocation(cleanPrompt); 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 !== null) {
if (invocation.name === "project") { if (invocation.name === "project") {
if (invocation.args.length > 0) { if (invocation.args.length > 0) {
await sendText(rt, chatId, "用法: /project(打开当前项目管理卡片)", sendOptionsForTriggerMessage(msg)); await sendText(rt, chatId, "用法: /project", sendOptionsForTriggerMessage(msg));
return; return;
} }
const project = await deps.prisma.project.findUniqueOrThrow({ await sendCard(
where: { id: projectId }, rt,
select: { id: true, name: true, organizationId: true }, chatId,
}); await projectConsoleCard(projectId, chatId, senderOpenId),
const organization = await resolveSingleActiveOrganizationForFeishuUser(senderOpenId); sendOptionsForTriggerMessage(msg),
if (organization.status !== "ok" || organization.organizationId !== project.organizationId) { );
await sendText(rt, chatId, "当前身份不属于该项目组织。", sendOptionsForTriggerMessage(msg)); deps.logger.info({ chatId, projectId, senderOpenId }, "feishu project console opened");
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");
return; return;
} }
const slashCommand = slashCommands.get(invocation.name); const slashCommand = slashCommands.get(invocation.name);
@@ -1146,8 +1343,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return; return;
} }
} }
const name = invocation?.name ?? cleanPrompt.slice(1).trim();
await startAgentRun(runContext, cleanPrompt); await sendText(rt, chatId, `未知 slash 命令 /${name}。使用 /help 查看可用命令。`, sendOptionsForTriggerMessage(msg));
return; return;
} }
@@ -1161,11 +1358,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await enqueueLockedTrigger(runContext, cleanPrompt); await enqueueLockedTrigger(runContext, cleanPrompt);
return; return;
} }
const key = messageBatchKey(chatId, senderOpenId); const key = messageBatchKey(chatId, senderOpenId, runContext.roleId);
if (!batchContexts.has(key)) { if (!batchContexts.has(key)) {
batchContexts.set(key, runContext); batchContexts.set(key, runContext);
} }
await messageBatcher.enqueue(chatId, senderOpenId, cleanPrompt); await messageBatcher.enqueue(chatId, senderOpenId, cleanPrompt, runContext.roleId);
return; return;
} }
@@ -1750,27 +1947,3 @@ async function compensateFailedResourceAdmission(
); );
} }
} }
/**
* Parse a leading `/<role>` 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) };
}
+3
View File
@@ -4,6 +4,9 @@ export interface QueuedTrigger {
readonly projectId: string; readonly projectId: string;
readonly chatId: string; readonly chatId: string;
readonly prompt: 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 msg: MessageReceiveEvent["message"];
readonly senderOpenId: string; readonly senderOpenId: string;
readonly actor: { readonly feishuOpenId: string; readonly chatId: string }; readonly actor: { readonly feishuOpenId: string; readonly chatId: string };
+32 -2
View File
@@ -165,6 +165,7 @@ export async function renameProjectForActor(
}); });
await tx.auditEntry.create({ await tx.auditEntry.create({
data: { data: {
organizationId: project.organizationId,
projectId: project.id, projectId: project.id,
actorUserId: actor.userId, actorUserId: actor.userId,
action: "project.renamed", action: "project.renamed",
@@ -243,6 +244,7 @@ export async function bindFeishuChatToProject(
const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId); const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId);
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await requireActiveOrganizationTx(tx, project.organizationId); await requireActiveOrganizationTx(tx, project.organizationId);
const defaultRole = await requireDefaultAgentRole(tx, project.organizationId);
const activeProjectBinding = await tx.projectGroupBinding.findFirst({ const activeProjectBinding = await tx.projectGroupBinding.findFirst({
where: { projectId: project.id, archivedAt: null }, where: { projectId: project.id, archivedAt: null },
select: { chatId: true }, select: { chatId: true },
@@ -258,7 +260,13 @@ export async function bindFeishuChatToProject(
throw new Error(`Feishu chat ${chatId} is already bound to project ${activeChatBinding.projectId}`); throw new Error(`Feishu chat ${chatId} is already bound to project ${activeChatBinding.projectId}`);
} }
await tx.projectGroupBinding.create({ 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, { await replaceProjectGrant(tx, {
projectId: project.id, projectId: project.id,
@@ -390,8 +398,15 @@ async function createManagedProject(
createdByUserId: input.actorUserId, createdByUserId: input.actorUserId,
}); });
if (input.chatId !== undefined) { if (input.chatId !== undefined) {
const defaultRole = await requireDefaultAgentRole(tx, organization.id);
await tx.projectGroupBinding.create({ 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, { await replaceProjectGrant(tx, {
projectId: created.id, 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<void> { async function requireActiveOrganization(prisma: PrismaClient, organizationId: string): Promise<void> {
const organization = await prisma.organization.findUnique({ const organization = await prisma.organization.findUnique({
where: { id: organizationId }, where: { id: organizationId },
+4 -3
View File
@@ -177,6 +177,10 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
if (project.organization.agentRoles.length === 0) { if (project.organization.agentRoles.length === 0) {
throw new Error(`no active Agent roles configured for organization ${project.organization.id}`); 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 defaults = await this.envSettings.modelRegistry(scope);
const enabledModels = new Set(defaults.listModels().map((model) => model.id)); 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); return new InMemoryModelRegistry(defaults.listModels(), roles);
} }
+6 -1
View File
@@ -206,7 +206,12 @@ describe("admin explorer API", () => {
it("blocks cross-org project access by id", async () => { it("blocks cross-org project access by id", async () => {
const token = await seedAdmin(); const token = await seedAdmin();
await prisma.organization.create({ 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({ await prisma.project.create({
data: { data: {
@@ -60,7 +60,10 @@ describe("Organization Agent configuration management", () => {
expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]); expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]);
expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]); expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]);
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } })) 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 () => { 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"); })).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<string> { async function makeSkill(parent: string, name: string): Promise<string> {
const source = join(parent, "sources", name); const source = join(parent, "sources", name);
await mkdir(source, { recursive: true }); await mkdir(source, { recursive: true });
@@ -42,22 +42,18 @@ describe("Organization-scoped Agent runtime configuration", () => {
}), }),
]); ]);
const [roleA, roleB] = await Promise.all([ const [roleA, roleB] = await Promise.all([
prisma.organizationAgentRole.create({ prisma.organizationAgentRole.update({
where: { organizationId_roleId: { organizationId: DEFAULT_ORG_ID, roleId: "draft" } },
data: { data: {
id: "role-a",
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
label: "A Draft", label: "A Draft",
defaultModel: "anthropic/claude-sonnet-5", defaultModel: "anthropic/claude-sonnet-5",
systemPrompt: "prompt-a", systemPrompt: "prompt-a",
tools: ["read_file", "cph_build"], tools: ["read_file", "cph_build"],
}, },
}), }),
prisma.organizationAgentRole.create({ prisma.organizationAgentRole.update({
where: { organizationId_roleId: { organizationId: "org_other", roleId: "draft" } },
data: { data: {
id: "role-b",
organizationId: "org_other",
roleId: "draft",
label: "B Draft", label: "B Draft",
systemPrompt: "prompt-b", systemPrompt: "prompt-b",
tools: [], tools: [],
@@ -105,8 +101,8 @@ describe("Organization-scoped Agent runtime configuration", () => {
disabledAt: new Date(), disabledAt: new Date(),
}, },
}); });
const role = await prisma.organizationAgentRole.create({ const role = await prisma.organizationAgentRole.findUniqueOrThrow({
data: { id: "role-a", organizationId: DEFAULT_ORG_ID, roleId: "draft", label: "Draft" }, where: { organizationId_roleId: { organizationId: DEFAULT_ORG_ID, roleId: "draft" } },
}); });
await prisma.organizationAgentRoleSkill.create({ await prisma.organizationAgentRoleSkill.create({
data: { organizationId: DEFAULT_ORG_ID, agentRoleId: role.id, agentSkillId: skill.id }, data: { organizationId: DEFAULT_ORG_ID, agentRoleId: role.id, agentSkillId: skill.id },
@@ -118,7 +118,12 @@ describe("Feishu binding lifecycle events", () => {
reason: "bot_removed", reason: "bot_removed",
}); });
await prisma.projectGroupBinding.create({ 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, { await expect(archiveFeishuBindingForLifecycleEvent(prisma, {
+34 -14
View File
@@ -55,19 +55,33 @@ export async function seedTestOrganization(
id: string = DEFAULT_ORG_ID, id: string = DEFAULT_ORG_ID,
slug: string = "test-default", slug: string = "test-default",
): Promise<void> { ): Promise<void> {
await prisma.organization.upsert({ await prisma.$transaction(async (tx) => {
where: { id }, await tx.organization.upsert({
update: {}, where: { id },
create: { update: {},
id, create: { id, slug, name: "Test Default Organization" },
slug, });
name: "Test Default Organization", await tx.organizationProjectSettings.upsert({
}, where: { organizationId: id },
}); update: {},
await prisma.organizationProjectSettings.upsert({ create: { organizationId: id, membersCanCreateProjects: true },
where: { organizationId: id }, });
update: {}, const defaultRole = await tx.organizationAgentRole.upsert({
create: { organizationId: id, membersCanCreateProjects: true }, 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({ const inbox = await prisma.folder.findFirst({
where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null }, where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
@@ -400,6 +414,12 @@ export async function seedProject(
}, },
}); });
await prisma.projectGroupBinding.create({ 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}`,
},
}); });
} }
@@ -262,6 +262,30 @@ describe("ADR-0021 project onboarding", () => {
}); });
expect(oldChatGrant).toBeNull(); 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<void> { async function seedUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise<void> {
+4 -3
View File
@@ -23,6 +23,7 @@ describe("Alpha Silo bootstrap", () => {
id: "org_default", id: "org_default",
slug: "legacy-default", slug: "legacy-default",
name: "Legacy Default Organization", name: "Legacy Default Organization",
agentRoles: { create: { roleId: "draft", label: "草稿", isDefault: true } },
projectSettings: { create: { membersCanCreateProjects: true } }, projectSettings: { create: { membersCanCreateProjects: true } },
folders: { folders: {
create: { create: {
@@ -56,10 +57,10 @@ describe("Alpha Silo bootstrap", () => {
await expect(prisma.organizationAgentRole.findMany({ await expect(prisma.organizationAgentRole.findMany({
where: { organizationId: "org_alpha", disabledAt: null }, where: { organizationId: "org_alpha", disabledAt: null },
orderBy: { sortOrder: "asc" }, orderBy: { sortOrder: "asc" },
select: { roleId: true, label: true }, select: { roleId: true, label: true, isDefault: true },
})).resolves.toEqual([ })).resolves.toEqual([
{ roleId: "draft", label: "草稿" }, { roleId: "draft", label: "草稿", isDefault: true },
{ roleId: "review", label: "审校" }, { roleId: "review", label: "审校", isDefault: false },
]); ]);
const persisted = JSON.stringify({ const persisted = JSON.stringify({
+236 -378
View File
@@ -13,7 +13,6 @@ import {
silentLogger, silentLogger,
} from "./helpers.js"; } from "./helpers.js";
import { InMemoryModelRegistry } from "../../src/agent/models.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 { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
import { TriggerQueue } from "../../src/feishu/triggerQueue.js"; import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.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第二段"); 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 () => { it("keeps the project session shared while labeling each sender in the prompt", async () => {
await seedProject("proj-speaker", "chat-speaker"); await seedProject("proj-speaker", "chat-speaker");
await prisma.permissionGrant.create({ await prisma.permissionGrant.create({
@@ -677,24 +710,7 @@ describe("trigger full lifecycle (integration)", () => {
expect(sessions).toHaveLength(1); expect(sessions).toHaveLength(1);
}); });
it("/new bypasses message batching", async () => { it("/help lists only the closed Hub slash protocol", 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 () => {
await seedProject("proj-help", "chat-help"); await seedProject("proj-help", "chat-help");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); 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) ?? ""; const helpText = rt.sentTexts.at(-1) ?? "";
expect(helpText).toContain("可用 slash 命令"); expect(helpText).toContain("可用 slash 命令");
expect(helpText).toContain("/new");
expect(helpText).toContain("/reset");
expect(helpText).toContain("/project"); expect(helpText).toContain("/project");
expect(helpText).toContain("/draft <需求>"); expect(helpText).toContain("/usage");
expect(helpText).toContain("/review <需求>"); expect(helpText).not.toMatch(/\/(?:new|reset|resume|draft|review|compact)\b/);
expect(runAgentCalls).toHaveLength(0); expect(runAgentCalls).toHaveLength(0);
expect(await prisma.agentRun.findMany()).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"); await seedProject("proj-cost", "chat-cost");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); 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"); 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) ?? ""; const costText = rt.sentTexts.at(-1) ?? "";
expect(costText).toContain("当前会话已记录 agent 成本"); expect(costText).toContain("当前角色会话用量");
expect(costText).toContain("总计: $0.0023"); expect(costText).toContain("$0.0023");
expect(costText).toContain("Runs: 1 已记录");
expect(costText).toContain("openrouter / mock-model"); expect(costText).toContain("openrouter / mock-model");
expect(runAgentCalls).toHaveLength(1); expect(runAgentCalls).toHaveLength(1);
expect(await prisma.agentRun.findMany()).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 <command> returns command-specific help", async () => { it("/help <command> 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 } }); 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) ?? ""; const helpText = rt.sentTexts.at(-1) ?? "";
expect(helpText).toContain("/reset"); expect(helpText).toContain("/usage");
expect(helpText).toContain("清空当前项目已经排队"); expect(helpText).toContain("真实 Agent 用量与成本");
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(runAgentCalls).toHaveLength(0); expect(runAgentCalls).toHaveLength(0);
expect(await prisma.agentRun.findMany()).toHaveLength(0); expect(await prisma.agentRun.findMany()).toHaveLength(0);
}); });
@@ -958,7 +846,7 @@ describe("trigger full lifecycle (integration)", () => {
}); });
it.each(["SUSPENDED", "ARCHIVED"] as const)( 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) => { async (status) => {
await seedProject(`proj-org-${status}`, `chat-org-${status}`); await seedProject(`proj-org-${status}`, `chat-org-${status}`);
const session = await prisma.agentSession.create({ 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 } }); await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); 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(rt.sentTexts).toContain("无权限触发。");
expect(runAgentCalls).toHaveLength(0); 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 () => { it("queues a text trigger when project is already locked (ADR-0002)", async () => {
await seedProject("proj-3", "chat-3"); await seedProject("proj-3", "chat-3");
// Manually create a lock by inserting a run + lock. // 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<RunResult>();
const secondRun = deferred<RunResult>();
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 () => { it("reauthorizes a queued trigger and drops it after the organization is suspended", async () => {
await seedProject("proj-queue-suspended", "chat-queue-suspended"); await seedProject("proj-queue-suspended", "chat-queue-suspended");
const firstRun = deferred<RunResult>(); const firstRun = deferred<RunResult>();
@@ -1331,173 +1188,174 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs).toHaveLength(0); 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"); await seedProject("proj-6", "chat-6");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
// First @bot creates a session + run. await trigger(makeEvent("chat-6", "@_user_1 /project"), rt);
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");
});
// /new archives the session. expect(rt.sentCards).toHaveLength(1);
await trigger(makeEvent("chat-6", "@_user_1 /new"), rt); const card = JSON.stringify(rt.sentCards[0]);
expect(rt.sentTexts).toContain("已开新会话,下次 @bot 将从头开始。"); expect(card).toContain("当前角色");
expect(card).toContain("草稿");
const sessions = await prisma.agentSession.findMany(); expect(card).toContain("新开会话");
expect(sessions).toHaveLength(1); expect(card).toContain("历史会话");
expect(sessions[0]?.archivedAt).not.toBeNull(); expect(runAgentCalls).toHaveLength(0);
// No new run created for /new. expect(await prisma.agentRun.count()).toBe(0);
expect(await prisma.agentRun.findMany()).toHaveLength(1);
}); });
it("/resume un-archives the most recent session", async () => { it("creates, lists, and resumes the selected role's user-managed session history", async () => {
await seedProject("proj-7", "chat-7"); 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 } }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
// Create + archive a session via /new. await trigger.onCardAction(makeOnboardingEvent("chat-role-switch", {
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt); 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 () => { 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); expect(runAgentCalls[0]?.tools).toEqual(["read_file"]);
await expect(prisma.agentRun.findFirstOrThrow({ where: { projectId: "proj-role-switch" } })).resolves.toMatchObject({
// /resume un-archives. metadata: expect.objectContaining({ roleId: "review" }),
await trigger(makeEvent("chat-7", "@_user_1 /resume"), rt); });
expect(rt.sentTexts).toContain("已恢复上一个会话。"); await expect(prisma.projectGroupBinding.findFirstOrThrow({
where: { projectId: "proj-role-switch", chatId: "chat-role-switch", archivedAt: null },
const sessions = await prisma.agentSession.findMany(); select: { selectedAgentRoleId: true },
expect(sessions).toHaveLength(1); })).resolves.toEqual({ selectedAgentRoleId: reviewRole.id });
expect(sessions[0]?.archivedAt).toBeNull();
}); });
it("/reset archives current session", async () => { it("unknown slash commands fail visibly instead of reaching the Agent", 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 () => {
await seedProject("proj-9", "chat-9"); await seedProject("proj-9", "chat-9");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt); await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt);
// Should create a run (falls through as a normal prompt). expect(rt.sentTexts.at(-1)).toContain("未知 slash 命令 /unknown");
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(runAgentCalls).toHaveLength(0); expect(runAgentCalls).toHaveLength(0);
const runs = await prisma.agentRun.findMany(); expect(await prisma.agentRun.count()).toBe(0);
expect(runs).toHaveLength(0);
}); });
it("allows /review when sender holds the role grant", async () => { it("sends native /compact as an exact SDK prompt for the current role session", async () => {
await seedProject("proj-11", "chat-11"); await seedProject("proj-compact", "chat-compact");
await prisma.roleTriggerGrant.create({
data: { projectId: "proj-11", roleId: "review", principalType: "USER", principalId: "ou_test_user" },
});
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); 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 () => { await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany(); await expect(prisma.agentRun.findFirstOrThrow({ where: { projectId: "proj-compact" } }))
expect(runs).toHaveLength(1); .resolves.toMatchObject({ status: "COMPLETED" });
expect(runs[0]?.status).toBe("COMPLETED");
expect(runs[0]?.metadata).toMatchObject({ roleId: "review" });
}); });
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 () => { it("preserves native /compact semantics while the action waits in the project queue", async () => {
await seedProject("proj-12", "chat-12"); await seedProject("proj-compact-queued", "chat-compact-queued");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); const activeRun = deferred<RunResult>();
const queuedRunner: TestRunner = async (req) => {
await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt); 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 () => { await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany(); expect(await prisma.agentRun.count({ where: { projectId: "proj-compact-queued", status: "COMPLETED" } })).toBe(1);
expect(runs).toHaveLength(1);
expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "写第三单元");
expect(runs[0]?.metadata).toMatchObject({ roleId: "draft" });
}); });
}); 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 () => { activeRun.resolve(completedRunResult("active done", "sdk-session-active"));
await seedProject("proj-12b", "chat-12b"); await vi.waitFor(() => expect(runAgentCalls).toHaveLength(3));
await prisma.roleTriggerGrant.create({ expect(runAgentCalls[2]?.prompt).toBe("/compact");
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();
}); });
it("dedups a redelivered event by event_id (no second run)", async () => { it("dedups a redelivered event by event_id (no second run)", async () => {
+1 -1
View File
@@ -274,7 +274,7 @@ function mockPrisma(): PrismaClient {
create: vi.fn(async () => ({ id: "receipt-1" })), create: vi.fn(async () => ({ id: "receipt-1" })),
}, },
projectGroupBinding: { projectGroupBinding: {
findFirst: vi.fn(async () => ({ projectId: "project-1" })), findFirst: vi.fn(async () => ({ projectId: "project-1", selectedRole: { roleId: "draft" } })),
}, },
project: { project: {
findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })), findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })),
+3 -9
View File
@@ -1,17 +1,11 @@
import { describe, expect, it } from "vitest"; 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", () => { describe("slash command parser", () => {
it("parses a slash invocation without resolving it", () => { it("parses a slash invocation without resolving it", () => {
expect(parseSlashInvocation("/new")).toEqual({ name: "new", args: [] }); expect(parseSlashInvocation("/project")).toEqual({ name: "project", args: [] });
expect(parseSlashInvocation("/review 看看这节")).toEqual({ name: "review", args: ["看看这节"] }); expect(parseSlashInvocation("/usage project")).toEqual({ name: "usage", args: ["project"] });
expect(parseSlashInvocation("写教案")).toBeNull(); expect(parseSlashInvocation("写教案")).toBeNull();
}); });
it("parses help subcommands only in the exact /<command> 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();
});
}); });
+2
View File
@@ -117,6 +117,8 @@ function makeTrigger(prompt: string): Omit<QueuedTrigger, "projectId" | "enqueue
return { return {
chatId: "chat-1", chatId: "chat-1",
prompt, prompt,
roleId: "draft",
executionKind: "conversation",
msg: makeMessage(prompt), msg: makeMessage(prompt),
senderOpenId: "ou-user", senderOpenId: "ou-user",
actor: { feishuOpenId: "ou-user", chatId: "chat-1" }, actor: { feishuOpenId: "ou-user", chatId: "chat-1" },
+3
View File
@@ -26,6 +26,9 @@ structure Identifiers where
session 不跨 provider/model;切 model 即新 session,跨 session 连续性由 ADR-0003 项目 session 不跨 provider/model;切 model 即新 session,跨 session 连续性由 ADR-0003 项目
记忆/锚点重建,不由 session 自带。同 provider/model 内可跨多 run 复用, ADR-0002)。 -/ 记忆/锚点重建,不由 session 自带。同 provider/model 内可跨多 run 复用, ADR-0002)。 -/
SessionId : Type SessionId : Type
/-- Organization-scoped Agent role 标识(`OPEN` 表示, ADR-0017);role 是动态运行配置,
不是 slash command 枚举。 -/
AgentRoleId : Type
/-- 权限主体标识(`OPEN` 表示及其子类型学;ADR-0004 的 user/chat/department/ /-- 权限主体标识(`OPEN` 表示及其子类型学;ADR-0004 的 user/chat/department/
子类型学未定且非本层分歧点,纯 plumbing,故只留 opaque 键)。 -/ 子类型学未定且非本层分歧点,纯 plumbing,故只留 opaque 键)。 -/
Principal : Type Principal : Type
+40 -1
View File
@@ -1,7 +1,7 @@
import Spec.Prelude import Spec.Prelude
/-! /-!
# ProjectGroup —— 飞书项目群作为协作空间(ADR-0001) # ProjectGroup —— 飞书项目群作为协作空间(ADR-0001/0017)
ADR-0001 的核心:一个 project 对应一个**长生命周期**飞书项目群;群是协作空间,**不是锁 ADR-0001 的核心:一个 project 对应一个**长生命周期**飞书项目群;群是协作空间,**不是锁
owner**(锁归 `AgentRun`,见 `Lock` / ADR-0002),不是临时处理 session。群可在无 Claude owner**(锁归 `AgentRun`,见 `Lock` / ADR-0002),不是临时处理 session。群可在无 Claude
@@ -12,6 +12,12 @@ project↔group 的**active**一对一绑定——likec4 画得出"project has g
**绑定历史(`PINNED`, ADR-0021):** active binding 严格 1:1;实现可以保留 archived **绑定历史(`PINNED`, ADR-0021):** active binding 严格 1:1;实现可以保留 archived
historical binding rows 供审计/纠错,但 `GroupBinding` 谓词只刻画当前 active 快照。 historical binding rows 供审计/纠错,但 `GroupBinding` 谓词只刻画当前 active 快照。
群解散/不可达的自动化处理仍为 `OPEN`;pilot 纠错由 org admin 显式归档绑定。 群解散/不可达的自动化处理仍为 `OPEN`;pilot 纠错由 org admin 显式归档绑定。
**当前角色(`PINNED`, ADR-0017):** active binding 选择一个 Organization-scoped Agent
role;普通群消息在接收时冻结该 role,随后即使群切换角色,已接收工作也不漂移。切换角色
只改变后续工作路由,不把不同 role 的 provider session 合并。每个 Organization 恰有一个
active 默认 role 用于初始化新 binding,role 名称不硬编码。切换共享 role 的 actor 必须同时
通过 project `agent.trigger` 与目标 role `role.trigger`;不额外要求 project `MANAGE`。
-/ -/
namespace Spec.System namespace Spec.System
@@ -36,4 +42,37 @@ project"。archived historical bindings 不在本快照不变式内。 -/
def GroupBinding.WellFormed (b : GroupBinding I) : Prop := def GroupBinding.WellFormed (b : GroupBinding I) : Prop :=
p₁ p₂ c, b p₁ = some c b p₂ = some c p₁ = p₂ p₁ p₂ c, b p₁ = some c b p₂ = some c p₁ = p₂
/-- 每个 Organization 的 active 默认 Agent role(`PINNED`, ADR-0017)。函数形状钉死每个
Organization 恰好一个默认值;role 是否 active 及租户归属由 `WellScoped` 约束。 -/
def OrganizationDefaultRole := I.OrganizationId I.AgentRoleId
/-- 默认 role 必须属于作为其 key 的同一个 Organization(`PINNED`, ADR-0017)。 -/
def OrganizationDefaultRole.WellScoped
(defaults : OrganizationDefaultRole I)
(roleOrg : I.AgentRoleId Option I.OrganizationId) : Prop :=
o, roleOrg (defaults o) = some o
/-- Active 项目群选择的 Agent role(`PINNED`, ADR-0017)。role 属于项目 Organization;
具体外键表示是 plumbing,由 `WellScoped` 钉死租户边界。 -/
structure GroupSelectedRole where
/-- 被选择 role 的项目。 -/
project : I.ProjectId
/-- 作用于该项目 active 群的动态 Agent role。 -/
role : I.AgentRoleId
/-- 群所选 role 必须与 project 同 Organization(`PINNED`, ADR-0017)。 -/
def GroupSelectedRole.WellScoped
(selection : GroupSelectedRole I)
(projectOrg : I.ProjectId Option I.OrganizationId)
(roleOrg : I.AgentRoleId Option I.OrganizationId) : Prop :=
o, projectOrg selection.project = some o roleOrg selection.role = some o
/-- 已接收群工作冻结其 role(`PINNED`, ADR-0017):调度时使用 admission 中记录的 role,
而不是重新读取可能已经变化的群当前 role。 -/
structure GroupRunRoleSnapshot where
/-- 被接收的一次 run。 -/
run : I.RunId
/-- 接收时冻结的 role。 -/
role : I.AgentRoleId
end Spec.System end Spec.System