From 98f4f228e272850e8adf274ceb70bf0f6bbab688 Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 11 Jul 2026 02:58:57 +0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(trust):=20multi-agent=20workspaces=20?= =?UTF-8?q?=E2=80=94=20provenance=20chips,=20per-agent=20grant=20scoping,?= =?UTF-8?q?=20rate=20budgets=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/multi-agent-workspaces.md | 33 +++ packages/core/src/queries.ts | 3 + packages/core/src/store.test.ts | 43 +++- packages/core/src/store.ts | 40 ++-- packages/core/src/transforms/approvals.ts | 7 + .../core/src/transforms/transforms.test.ts | 29 +++ packages/core/src/types.ts | 2 + packages/lit/src/agent-provenance.test.ts | 86 ++++++++ packages/lit/src/agent-provenance.ts | 93 ++++++++ packages/lit/src/boardstate-view.test.ts | 91 ++++++++ packages/lit/src/boardstate-view.ts | 77 +++++++ packages/lit/src/boardstate-widget-cell.ts | 42 +++- packages/lit/src/locales/de.ts | 6 + packages/lit/src/locales/es.ts | 6 + packages/lit/src/locales/fr.ts | 6 + packages/lit/src/locales/ja-JP.ts | 6 + packages/lit/src/locales/zh-CN.ts | 6 + packages/lit/src/renderers/approvals.ts | 20 ++ packages/lit/src/renderers/renderers.test.ts | 32 +++ packages/lit/src/strings.ts | 6 + packages/lit/src/styles/boardstate.css | 79 +++++++ packages/schema/SPEC.md | 43 +++- packages/schema/src/schema.test.ts | 47 ++++ packages/schema/src/schema.ts | 39 ++++ packages/server/src/broker-actions.test.ts | 202 +++++++++++++++++- packages/server/src/broker-actions.ts | 140 +++++++++--- .../server/src/broker-agent-tools.test.ts | 45 +++- packages/server/src/broker-agent-tools.ts | 40 +++- packages/server/src/broker-workspace.ts | 12 ++ packages/server/src/host.ts | 15 ++ packages/server/src/rpc.ts | 39 +++- packages/server/src/ws-transport.test.ts | 74 +++++++ 32 files changed, 1352 insertions(+), 57 deletions(-) create mode 100644 .changeset/multi-agent-workspaces.md create mode 100644 packages/lit/src/agent-provenance.test.ts create mode 100644 packages/lit/src/agent-provenance.ts diff --git a/.changeset/multi-agent-workspaces.md b/.changeset/multi-agent-workspaces.md new file mode 100644 index 0000000..8fef8bd --- /dev/null +++ b/.changeset/multi-agent-workspaces.md @@ -0,0 +1,33 @@ +--- +"@boardstate/schema": minor +"@boardstate/core": minor +"@boardstate/server": minor +"@boardstate/lit": minor +--- + +Multi-agent workspaces (#59, SPEC §17.3): several agents sharing one board, distinguishable +and separately governed. + +- **Per-agent grant scoping (schema + engine).** A capability grant gains an optional + operator-set `agents?: string[]` — the ACTOR dimension of the AND-gate. Absent ⇒ all agents + (back-compat, zero migration); present ⇒ only those agent actors pass, at BOTH tool-set + assembly (the agent-tool adapter surfaces a scoped grant only to a bound, listed agent — + covering the direct `readOnly` path) and invoke/read time (`dashboard.action.invoke` / + `dashboard.connector.read` fail-safe recheck). Operator-set ONLY (the approve verb); + `tool_search` REQUEST / `workspace.replace` / import can never write or widen it — any scope + drift on a still-granted grant re-pends the whole grant, and every re-pend (manifest drift, + replace/import, REQUEST, TTL expiry, revoke) strips it, exactly like `autoConfirm`/`expiresAt`. +- **Actor authenticity (load-bearing).** The acting agent is bound from the server-side + session/tool-registration identity (threaded `RequestContext → RpcHandlerContext`), NEVER a + request param. A parked mutation records the server-bound requester and the confirm-time + re-gate re-checks scope against IT. The WS transport threads no identity, so a scoped grant + FAILS CLOSED for an unauthenticated networked caller (`capability_pending`) — a client-claimed + `actor` can never pass another agent's scope (wire-contract tested). +- **Per-agent rate budgets.** The per-connector invoke limit gains an optional + `perAgentInvokeRateMax`: an agent's ceiling becomes `min(connector, per-agent)`. Unset ⇒ + connector-only, byte-identical to prior behavior. +- **Provenance chips + filter (lit).** On a board with ≥2 distinct agent authors, each widget + header shows a compact deterministically-coloured chip (short id, full actor on hover) and a + toolbar affordance filters/highlights one agent's widgets. The approvals widget renders each + grant's per-agent scope. Zero schema change; single-agent boards are unchanged. New i18n keys + added to the five complete locales. diff --git a/packages/core/src/queries.ts b/packages/core/src/queries.ts index d5850fe..1b17036 100644 --- a/packages/core/src/queries.ts +++ b/packages/core/src/queries.ts @@ -200,6 +200,9 @@ function normalizeCapabilityGrant(value: unknown): DashboardCapabilityGrant | nu // pre-#62/#64 grant normalizes byte-identically (no new keys invented on output). ...(Array.isArray(value.autoConfirm) ? { autoConfirm: strings(value.autoConfirm) } : {}), ...(typeof value.expiresAt === "string" ? { expiresAt: value.expiresAt } : {}), + // `agents` (SPEC §17.3, #59) carried only when present, so an unscoped grant + // normalizes byte-identically (no new key invented on output). + ...(Array.isArray(value.agents) ? { agents: strings(value.agents) } : {}), ...(typeof value.description === "string" ? { description: value.description } : {}), ...(typeof value.grantedBy === "string" ? { grantedBy: value.grantedBy } : {}), ...(typeof value.grantedAt === "string" ? { grantedAt: value.grantedAt } : {}), diff --git a/packages/core/src/store.test.ts b/packages/core/src/store.test.ts index 6ec7ef5..fe060be 100644 --- a/packages/core/src/store.test.ts +++ b/packages/core/src/store.test.ts @@ -322,6 +322,7 @@ describe("DashboardStore", () => { toolsHash: "hash-send", autoConfirm: ["officecli:send_mail"], expiresAt: new Date(1_000).toISOString(), + agents: ["agent:alice"], grantedBy: "user", grantedAt: new Date(500).toISOString(), }, @@ -330,7 +331,7 @@ describe("DashboardStore", () => { return result.doc.workspaceVersion; } - it("re-pends a lapsed grant on read — clears autoConfirm + expiresAt, bumps version once", async () => { + it("re-pends a lapsed grant on read — clears autoConfirm + expiresAt + agents, bumps version once", async () => { await withTempStateDir(async (stateDir) => { const seededVersion = await seedGrant(stateDir); const reader = storeAt(stateDir, { now: () => 2_000 }); @@ -339,6 +340,7 @@ describe("DashboardStore", () => { expect(grant.status).toBe("requested"); expect(grant.autoConfirm).toBeUndefined(); expect(grant.expiresAt).toBeUndefined(); + expect(grant.agents).toBeUndefined(); expect(grant.grantedBy).toBeUndefined(); // Tools survive (the operator re-approves the SAME surface); version bumped once. expect(grant.tools).toEqual(["officecli:send_mail"]); @@ -584,6 +586,45 @@ describe("replaceSanitized (approval gate, SPEC §8.2)", () => { expect(out.capabilitiesRegistry!.officecli!.status).toBe("requested"); expect(out.capabilitiesRegistry!.officecli!.grantedBy).toBeUndefined(); }); + + it("re-pends + strips a granted grant whose per-agent scope drifts via replace (SPEC §17.3, #59)", () => { + // Both a WIDEN (dropping the scope entirely) and a re-scope are silent widenings + // through the agent path — the single reconcile gate re-pends and strips `agents`. + const grant = (agents: string[] | undefined) => ({ + capabilitiesRegistry: { + officecli: { + status: "granted", + methods: [], + streams: [], + tools: ["officecli:read_mail"], + toolsHash: "hash-x", + grantedBy: "user", + ...(agents ? { agents } : {}), + }, + }, + }); + // A) dropping the scope (scoped -> all agents) re-pends. + const widened = reconcileReplaceApproval( + grant(undefined) as never, + grant(["agent:alice"]) as never, + ); + expect(widened.capabilitiesRegistry!.officecli!.status).toBe("requested"); + expect(widened.capabilitiesRegistry!.officecli!.grantedBy).toBeUndefined(); + // B) re-scoping to a different agent re-pends + strips. + const rescoped = reconcileReplaceApproval( + grant(["agent:bob"]) as never, + grant(["agent:alice"]) as never, + ); + expect(rescoped.capabilitiesRegistry!.officecli!.status).toBe("requested"); + expect(rescoped.capabilitiesRegistry!.officecli!.agents).toBeUndefined(); + // An UNCHANGED scope survives untouched (no false re-pend). + const noop = reconcileReplaceApproval( + grant(["agent:alice"]) as never, + grant(["agent:alice"]) as never, + ); + expect(noop.capabilitiesRegistry!.officecli!.status).toBe("granted"); + expect(noop.capabilitiesRegistry!.officecli!.agents).toEqual(["agent:alice"]); + }); }); async function viWaitFor(assertion: () => void): Promise { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 21794c7..8e0c870 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -97,12 +97,15 @@ export function reconcileReplaceApproval( // run through here, so this single gate closes both. // // The OPERATOR-ONLY surface of a granted grant is (a) the authorized `tools`/`toolsHash` - // (SPEC §17.1), (b) the `autoConfirm` "always allow" set (SPEC §17.2, #62), and (c) the - // `expiresAt` TTL (SPEC §17 TTLs, #64). None may be MUTATED through replace/import: an - // agent appending a tool, ticking an auto-confirm, or extending a lease is a silent - // widening. Any drift in ANY of these on a still-granted grant re-pends the whole grant - // to `requested` so the operator re-approves the new surface — the single gate closes - // every no-agent-write path (agent workspace.replace, raw RPC replace, import). + // (SPEC §17.1), (b) the `autoConfirm` "always allow" set (SPEC §17.2, #62), (c) the + // `expiresAt` TTL (SPEC §17 TTLs, #64), and (d) the `agents` per-agent scope (SPEC §17.3, + // #59). None may be MUTATED through replace/import: an agent appending a tool, ticking an + // auto-confirm, extending a lease, or WIDENING the agent scope (dropping `agents`, or + // adding an actor) is a silent widening. Any drift in ANY of these on a still-granted + // grant re-pends the whole grant to `requested` so the operator re-approves the new + // surface — the single gate closes every no-agent-write path (agent workspace.replace, + // raw RPC replace, import). Operator-side narrowing is done through the approve verb, not + // here, so re-pend-on-any-drift is correct: this path never carries operator intent. const currentCaps = current.capabilitiesRegistry ?? {}; const incomingCaps = incoming.capabilitiesRegistry ?? {}; for (const [name, grant] of Object.entries(incomingCaps)) { @@ -114,15 +117,18 @@ export function reconcileReplaceApproval( (!sameStringSet(grant.tools ?? [], currentGrant.tools ?? []) || (grant.toolsHash ?? "") !== (currentGrant.toolsHash ?? "") || !sameStringSet(grant.autoConfirm ?? [], currentGrant.autoConfirm ?? []) || - (grant.expiresAt ?? "") !== (currentGrant.expiresAt ?? "")); + (grant.expiresAt ?? "") !== (currentGrant.expiresAt ?? "") || + !sameStringSet(grant.agents ?? [], currentGrant.agents ?? [])); if (selfElevated || surfaceMutated) { grant.status = "requested"; delete grant.grantedBy; delete grant.grantedAt; - // A re-pended grant carries no active auto-run or lease — strip the operator-only - // fields so a self-grant smuggled through replace can never resurrect them. + // A re-pended grant carries no active auto-run, lease, or scope — strip the + // operator-only fields so a self-grant smuggled through replace can never resurrect + // them (and a widened scope never rides along on a re-pended grant). delete grant.autoConfirm; delete grant.expiresAt; + delete grant.agents; } } return incoming; @@ -210,8 +216,9 @@ function sweepExpiredEphemeral(doc: WorkspaceDoc, nowMs: number): WorkspaceDoc | * Re-pend capability grants whose TTL has lapsed (SPEC §17 grant TTLs, #64). A * `granted` grant with an `expiresAt` at or before `nowMs` flips back to `requested` * — the standard re-pend: tools drop from the agent's set next turn, mcp bindings - * surface `capability_pending`, and (crucially) `autoConfirm` is cleared so a lapsed - * lease can never keep auto-running. `grantedBy`/`grantedAt`/`expiresAt` are stripped, + * surface `capability_pending`, and (crucially) `autoConfirm` + `agents` are cleared so a + * lapsed lease can never keep auto-running or stay scoped. `grantedBy`/`grantedAt`/`expiresAt` + * are stripped, * exactly like import re-pend. Returns a version-bumped doc when anything expired, or * null when nothing did — so `read()` writes exactly once, folded with the ephemeral * sweep. Mirrors `sweepExpiredEphemeral`: SWEEP ON READ is the universal, fail-closed @@ -238,7 +245,16 @@ function sweepExpiredGrants(doc: WorkspaceDoc, nowMs: number): WorkspaceDoc | nu continue; } expired = true; - const { grantedBy: _by, grantedAt: _at, expiresAt: _exp, autoConfirm: _auto, ...rest } = grant; + // A lapsed lease re-pends bare: strip provenance, the lease, the auto-run set, AND the + // per-agent scope (SPEC §17.3, #59) — the operator re-scopes on re-approval. + const { + grantedBy: _by, + grantedAt: _at, + expiresAt: _exp, + autoConfirm: _auto, + agents: _agents, + ...rest + } = grant; next[name] = { ...rest, status: "requested" }; } if (!expired) { diff --git a/packages/core/src/transforms/approvals.ts b/packages/core/src/transforms/approvals.ts index bbf41c7..4c0d2ea 100644 --- a/packages/core/src/transforms/approvals.ts +++ b/packages/core/src/transforms/approvals.ts @@ -76,6 +76,12 @@ export type PendingApprovalItem = { * it expires, so the widget can render a live countdown. */ expiresAt?: string; + /** + * For a per-agent-scoped `capability` grant (SPEC §17.3, #59): the agent actors the + * grant is scoped to. Absent ⇒ all agents (the grant is unscoped); present ⇒ the widget + * renders the scope so the operator sees WHICH agents may use the granted tools. + */ + agents?: string[]; }; /** @@ -205,6 +211,7 @@ export function buildApprovalsSource( ...((grant.tools ?? []).length ? { tools: grant.tools } : {}), ...((grant.autoConfirm ?? []).length ? { autoConfirm: grant.autoConfirm } : {}), ...(grant.expiresAt ? { expiresAt: grant.expiresAt } : {}), + ...((grant.agents ?? []).length ? { agents: grant.agents } : {}), })); const pendingActions: PendingApprovalItem[] = (actions?.pending ?? []).map((action) => ({ id: action.id, diff --git a/packages/core/src/transforms/transforms.test.ts b/packages/core/src/transforms/transforms.test.ts index 86f8c94..c6d29f5 100644 --- a/packages/core/src/transforms/transforms.test.ts +++ b/packages/core/src/transforms/transforms.test.ts @@ -518,6 +518,35 @@ describe("approvals mapping", () => { expect(source.pending[0]!.tools).toEqual(["officecli:send_mail"]); }); + it("surfaces a granted grant's per-agent scope on the management row (SPEC §17.3, #59)", () => { + const ws = workspace(); + ws.capabilitiesRegistry = { + officecli: { + status: "granted", + methods: [], + streams: [], + tools: ["officecli:send_mail"], + agents: ["agent:alice", "agent:bob"], + }, + }; + const source = buildApprovalsSource( + ws, + () => {}, + () => {}, + ); + const row = source.pending.find((item) => item.id === "officecli"); + expect(row?.granted).toBe(true); + expect(row?.agents).toEqual(["agent:alice", "agent:bob"]); + // An unscoped granted grant carries no `agents` (the row renders "All agents"). + ws.capabilitiesRegistry.officecli!.agents = undefined; + const source2 = buildApprovalsSource( + ws, + () => {}, + () => {}, + ); + expect(source2.pending.find((item) => item.id === "officecli")?.agents).toBeUndefined(); + }); + it("threads a partial-grant tools subset through onDecide to resolveCapability (§17.1)", () => { const ws = workspace(); ws.capabilitiesRegistry = { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 96eb48f..b3a869e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -111,6 +111,8 @@ export type DashboardCapabilityGrant = { autoConfirm?: string[]; /** ISO-8601 instant after which the grant re-pends to `requested` (SPEC §17, #64). */ expiresAt?: string; + /** Operator-set per-agent scope (SPEC §17.3, #59): agent actors this grant is usable by. */ + agents?: string[]; description?: string; grantedBy?: DashboardCreatedBy; grantedAt?: string; diff --git a/packages/lit/src/agent-provenance.test.ts b/packages/lit/src/agent-provenance.test.ts new file mode 100644 index 0000000..bad486b --- /dev/null +++ b/packages/lit/src/agent-provenance.test.ts @@ -0,0 +1,86 @@ +// Render-model coverage for the multi-agent provenance chips (SPEC §17.3, #59): the pure +// helpers the view + cell consume. No DOM here — the view integration is exercised in +// boardstate-view.test.ts. + +import { describe, expect, it } from "vitest"; +import type { DashboardWorkspace } from "@boardstate/core"; +import { + agentChipFor, + agentHue, + distinctAgentActors, + isMultiAgentBoard, + shortAgentId, +} from "./agent-provenance.js"; + +function board(...createdBy: (string | undefined)[]): DashboardWorkspace { + return { + schemaVersion: 1, + workspaceVersion: 1, + capabilitiesRegistry: {}, + prefs: { tabOrder: ["t"] }, + tabs: [ + { + slug: "t", + title: "T", + hidden: false, + widgets: createdBy.map((actor, i) => ({ + id: `w${i}`, + kind: "builtin:markdown", + title: "W", + grid: { x: 0, y: 0, w: 1, h: 1 }, + collapsed: false, + ...(actor ? { createdBy: actor } : {}), + })), + }, + ], + } as unknown as DashboardWorkspace; +} + +describe("agentHue", () => { + it("is deterministic and in [0, 360)", () => { + const a = agentHue("agent:alice"); + expect(a).toBe(agentHue("agent:alice")); + expect(a).toBeGreaterThanOrEqual(0); + expect(a).toBeLessThan(360); + }); + + it("spreads distinct actors to (usually) distinct hues", () => { + expect(agentHue("agent:alice")).not.toBe(agentHue("agent:bob")); + }); +}); + +describe("shortAgentId", () => { + it("passes through short ids and ellipsizes long ones", () => { + expect(shortAgentId("alice")).toBe("alice"); + expect(shortAgentId("a-very-long-agent-identifier")).toHaveLength(10); + expect(shortAgentId("a-very-long-agent-identifier").endsWith("…")).toBe(true); + }); +}); + +describe("distinctAgentActors + isMultiAgentBoard", () => { + it("counts only distinct agent actors, ignoring user/system/unstamped", () => { + const ws = board("agent:alice", "agent:bob", "agent:alice", "user", "system", undefined); + expect(distinctAgentActors(ws)).toEqual(["agent:alice", "agent:bob"]); + expect(isMultiAgentBoard(ws)).toBe(true); + }); + + it("is NOT multi-agent with a single agent (chips stay hidden)", () => { + const ws = board("agent:alice", "agent:alice", "user"); + expect(isMultiAgentBoard(ws)).toBe(false); + }); +}); + +describe("agentChipFor", () => { + it("returns null for a non-agent author", () => { + expect(agentChipFor("user", null)).toBeNull(); + expect(agentChipFor("system", null)).toBeNull(); + }); + + it("builds a chip and marks it dimmed only when another agent is highlighted", () => { + const chip = agentChipFor("agent:alice", "agent:bob"); + expect(chip).toMatchObject({ actor: "agent:alice", agentId: "alice", short: "alice" }); + expect(chip!.dimmed).toBe(true); + expect(agentChipFor("agent:alice", "agent:alice")!.dimmed).toBe(false); + expect(agentChipFor("agent:alice", null)!.dimmed).toBe(false); + }); +}); diff --git a/packages/lit/src/agent-provenance.ts b/packages/lit/src/agent-provenance.ts new file mode 100644 index 0000000..4d21f85 --- /dev/null +++ b/packages/lit/src/agent-provenance.ts @@ -0,0 +1,93 @@ +// Multi-agent provenance render-model (SPEC §13 provenance + §17.3, #59). When a board +// carries widgets from ≥2 distinct agent actors, each widget header gets a compact, +// deterministically-coloured chip (short id, full actor on hover) and the toolbar offers a +// filter that highlights one agent's widgets. This module is PURE (no DOM, no lit) so the +// render-model is unit-testable in isolation; the view + cell consume it. + +import { dashboardAgentProvenance, type DashboardWorkspace } from "@boardstate/core"; + +/** The chip model for one widget's agent author. `dimmed` is set by the active filter. */ +export type AgentChipModel = { + /** Full actor string, e.g. `agent:alice` — shown as the chip's `title` (hover). */ + actor: string; + /** The bare agent id (`alice`) — the provenance segment after `agent:`. */ + agentId: string; + /** A short, header-safe label (the agent id, capped) — the chip's visible text. */ + short: string; + /** Deterministic hue 0–359 derived from the actor; stable across renders + reloads. */ + hue: number; + /** True when a filter is active and this chip's agent is NOT the highlighted one. */ + dimmed: boolean; +}; + +const SHORT_ID_MAX = 10; + +/** + * A stable 32-bit FNV-1a hash of a string → a hue in [0, 360). Deterministic and + * reload-stable (no `Math.random`, no insertion order), so an agent keeps one colour + * everywhere it appears. Not cryptographic — only a spread for visual distinction. + */ +export function agentHue(actor: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < actor.length; i++) { + hash ^= actor.charCodeAt(i); + // FNV prime multiply, kept in 32-bit unsigned space via Math.imul. + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0) % 360; +} + +/** Cap an agent id to a header-safe length, ellipsizing when it overflows. */ +export function shortAgentId(agentId: string): string { + return agentId.length <= SHORT_ID_MAX ? agentId : `${agentId.slice(0, SHORT_ID_MAX - 1)}…`; +} + +/** + * The distinct agent actors that authored a widget anywhere on the board, in stable + * (sorted) order. `user`/`system`-authored and unstamped widgets contribute nothing — + * only `agent:` provenance counts, since scoping + chips are per-AGENT. + */ +export function distinctAgentActors(workspace: DashboardWorkspace): string[] { + const actors = new Set(); + for (const tab of workspace.tabs) { + for (const widget of tab.widgets) { + const actor = widget.createdBy; + if (actor && dashboardAgentProvenance(actor)) { + actors.add(actor); + } + } + } + return [...actors].sort(); +} + +/** + * Whether the board is a MULTI-AGENT workspace: ≥2 distinct agent actors authored its + * widgets. Below that threshold the chips + filter stay hidden (a single-agent or + * operator-only board reads exactly as before — provenance is not noise until it + * distinguishes something). + */ +export function isMultiAgentBoard(workspace: DashboardWorkspace): boolean { + return distinctAgentActors(workspace).length >= 2; +} + +/** + * Build the chip model for a widget's `createdBy`, or `null` when there is no agent chip + * to show (the widget is not agent-authored). `highlightedAgent` (the active filter, or + * null) drives `dimmed`. + */ +export function agentChipFor( + actor: string, + highlightedAgent: string | null, +): AgentChipModel | null { + const agentId = dashboardAgentProvenance(actor); + if (!agentId) { + return null; + } + return { + actor, + agentId, + short: shortAgentId(agentId), + hue: agentHue(actor), + dimmed: highlightedAgent !== null && actor !== highlightedAgent, + }; +} diff --git a/packages/lit/src/boardstate-view.test.ts b/packages/lit/src/boardstate-view.test.ts index ba5b8dc..ac5edf5 100644 --- a/packages/lit/src/boardstate-view.test.ts +++ b/packages/lit/src/boardstate-view.test.ts @@ -467,3 +467,94 @@ describe("workspace header actions (wave-m2 / wave-w3 / wave-w5)", () => { expect(container.querySelector('[data-test-id="dashboard-import-input"]')).not.toBeNull(); }); }); + +/** A workspace with widgets authored by two distinct agents (a multi-agent board). */ +function multiAgentDoc() { + return { + schemaVersion: 1, + workspaceVersion: 1, + capabilitiesRegistry: {}, + widgetsRegistry: {}, + prefs: { tabOrder: ["main"] }, + tabs: [ + { + slug: "main", + title: "Main", + hidden: false, + widgets: [ + { + id: "wa", + kind: "builtin:markdown", + title: "Alice widget", + grid: { x: 0, y: 0, w: 6, h: 2 }, + collapsed: false, + createdBy: "agent:alice", + props: { markdown: "a" }, + }, + { + id: "wb", + kind: "builtin:markdown", + title: "Bob widget", + grid: { x: 6, y: 0, w: 6, h: 2 }, + collapsed: false, + createdBy: "agent:bob", + props: { markdown: "b" }, + }, + ], + }, + ], + } as never; +} + +describe("multi-agent provenance chips (SPEC §17.3, #59)", () => { + it("renders a per-agent chip per widget + the filter bar when ≥2 agents author the board", () => { + const host = {}; + const state = getDashboardState(host); + state.loaded = true; + state.workspace = multiAgentDoc(); + state.activeSlug = "main"; + const container = renderView(host); + const chips = container.querySelectorAll('[data-test-id="dashboard-widget-agent-chip"]'); + expect(chips).toHaveLength(2); + expect([...chips].map((c) => c.getAttribute("data-agent")).sort()).toEqual([ + "agent:alice", + "agent:bob", + ]); + // The filter bar appears with a chip per agent + an "All" reset. + expect(container.querySelector('[data-test-id="dashboard-agent-filter"]')).not.toBeNull(); + expect(container.querySelectorAll('[data-test-id="dashboard-agent-filter-chip"]')).toHaveLength( + 2, + ); + }); + + it("hides chips + filter on a single-agent board (plain provenance chip only)", () => { + const host = {}; + const state = getDashboardState(host); + state.loaded = true; + state.workspace = doc; // the base doc: one unstamped widget + state.activeSlug = "main"; + const container = renderView(host); + expect(container.querySelector('[data-test-id="dashboard-widget-agent-chip"]')).toBeNull(); + expect(container.querySelector('[data-test-id="dashboard-agent-filter"]')).toBeNull(); + }); + + it("filtering to one agent dims the other agent's widgets", () => { + const host = {}; + const state = getDashboardState(host); + state.loaded = true; + state.workspace = multiAgentDoc(); + state.activeSlug = "main"; + const container = document.createElement("div"); + render(renderBoardstateView(baseProps(host)), container); + // Click the first agent's filter chip (alice — sorted first). + const aliceChip = container.querySelector( + '[data-test-id="dashboard-agent-filter-chip"]', + ); + aliceChip?.click(); + render(renderBoardstateView(baseProps(host)), container); + const bobCell = container.querySelector('[data-widget-id="wb"]'); + const aliceCell = container.querySelector('[data-widget-id="wa"]'); + expect(bobCell?.classList.contains("dashboard-widget--agent-dimmed")).toBe(true); + expect(aliceCell?.classList.contains("dashboard-widget--agent-dimmed")).toBe(false); + }); +}); diff --git a/packages/lit/src/boardstate-view.ts b/packages/lit/src/boardstate-view.ts index a0af05c..b665fd8 100644 --- a/packages/lit/src/boardstate-view.ts +++ b/packages/lit/src/boardstate-view.ts @@ -93,6 +93,7 @@ import { import { icons } from "./icons.js"; import type { BuiltinWidgetContext } from "./renderers/index.js"; import { setBoardstateStrings, t, type BoardstateStrings } from "./strings.js"; +import { agentChipFor, distinctAgentActors, shortAgentId, agentHue } from "./agent-provenance.js"; /** Minimal Web-Storage-shaped seam for the onboarding-dismissed flag. */ export interface BoardstateStorage { @@ -190,6 +191,12 @@ type DashboardViewState = { history: DashboardHistoryViewState; /** Widget-gallery browse/install surface state (w3), or null when closed. */ gallery: DashboardGalleryState | null; + /** + * Active per-agent provenance filter (SPEC §17.3, #59): the full actor (`agent:`) + * whose widgets are highlighted, or null for "show all". Transient view state; only + * meaningful on a multi-agent board. + */ + highlightedAgent: string | null; }; /** Bookkeeping for one live `stream`-binding subscription (see DashboardViewState). */ @@ -392,6 +399,7 @@ function getViewState(host: object, storage: BoardstateStorage | undefined): Das lastPresenceSlug: null, history: initialHistoryViewState(), gallery: null, + highlightedAgent: null, }; dashboardViewStates.set(host, state); } @@ -1279,6 +1287,9 @@ function renderGrid( const callbacks = makeCallbacks(props, state, viewState, tab); const rows = gridRowCount(tab.widgets); const minHeight = rows * DASHBOARD_ROW_HEIGHT + Math.max(0, rows - 1) * DASHBOARD_GRID_GAP; + // Multi-agent provenance chips (SPEC §17.3, #59) only appear once ≥2 distinct agent + // authors are on the board; below that the plain "AI" chip is used (byte-identical). + const multiAgent = distinctAgentActors(workspace).length >= 2; return html`
${tab.widgets.map((widget) => { @@ -1292,6 +1303,10 @@ function renderGrid( dragging && drag.mode === "move" ? `translate(${drag.pointerDx}px, ${drag.pointerDy}px)` : undefined; + const agentChip = + multiAgent && widget.createdBy + ? agentChipFor(widget.createdBy, viewState.highlightedAgent) + : null; return renderWidgetCell({ widget, binding: viewState.bindingResults.get(widget.id) ?? null, @@ -1303,6 +1318,7 @@ function renderGrid( builtinContext: buildBuiltinContext(props, state, workspace, widget), callbacks, ...(custom ? { custom } : {}), + ...(agentChip ? { agentChip } : {}), }); })} ${renderDragGhost(viewState, tab)} @@ -1738,10 +1754,71 @@ function renderBody( ${renderWorkspacesHeader(props, state, viewState, tab)} ${renderOnboardingBanner(props, viewState, workspace, () => props.onRequestUpdate?.())} ${renderTabStrip(props, state, viewState, workspace)} + ${renderAgentFilterBar(props, viewState, workspace)} ${renderGrid(props, state, viewState, workspace, tab)} `; } +/** + * The per-agent provenance filter (SPEC §17.3, #59): a row of deterministically-coloured + * agent chips shown ONLY on a multi-agent board (≥2 distinct agent authors). Clicking a + * chip highlights that agent's widgets (dimming the rest); clicking the active chip (or + * "All") clears the filter. A single-agent / operator board renders nothing here. + */ +function renderAgentFilterBar( + props: BoardstateViewProps, + viewState: DashboardViewState, + workspace: DashboardWorkspace, +): TemplateResult | typeof nothing { + const actors = distinctAgentActors(workspace); + if (actors.length < 2) { + viewState.highlightedAgent = null; // never strand a filter when the board narrows + return nothing; + } + const setHighlight = (actor: string | null): void => { + viewState.highlightedAgent = viewState.highlightedAgent === actor ? null : actor; + props.onRequestUpdate?.(); + }; + const active = viewState.highlightedAgent; + return html` +
+ ${t("dashboard.agentFilter.label")} + + ${actors.map((actor) => { + const agentId = dashboardAgentProvenance(actor) ?? actor; + const isActive = active === actor; + return html``; + })} +
+ `; +} + /** Trigger a browser download of `json` under `filename` (no-op outside a document). */ function downloadWorkspaceJson(filename: string, json: string): void { if (typeof document === "undefined" || typeof URL.createObjectURL !== "function") { diff --git a/packages/lit/src/boardstate-widget-cell.ts b/packages/lit/src/boardstate-widget-cell.ts index 302695b..8c743a2 100644 --- a/packages/lit/src/boardstate-widget-cell.ts +++ b/packages/lit/src/boardstate-widget-cell.ts @@ -21,6 +21,7 @@ import { import { icons } from "./icons.js"; import { getBuiltinRenderer, type BuiltinWidgetContext } from "./renderers/index.js"; import { t } from "./strings.js"; +import type { AgentChipModel } from "./agent-provenance.js"; export type DashboardWidgetCellCallbacks = { onToggleCollapse: (widget: DashboardWidget) => void; @@ -88,6 +89,14 @@ export type DashboardWidgetCellProps = { callbacks: DashboardWidgetCellCallbacks; /** Present for `custom:` widgets only; builtin widgets leave this undefined. */ custom?: DashboardCustomWidgetContext; + /** + * Multi-agent provenance chip (SPEC §17.3, #59) — set by the view ONLY on a + * multi-agent board (≥2 distinct agent authors) for an agent-authored widget. + * Replaces the plain "AI" chip with a per-agent coloured one; `dimmed` reflects the + * active header filter. Absent ⇒ the plain provenance chip (single-agent boards read + * exactly as before). + */ + agentChip?: AgentChipModel; }; /** @@ -99,12 +108,36 @@ export function displayWidgetTitle(title: string): string { return title.replace(/\s*\(custom\)\s*$/iu, "").trim() || title; } -/** Renders the provenance chip when a widget was authored by an agent. */ -function renderProvenanceChip(widget: DashboardWidget): TemplateResult | typeof nothing { +/** + * Renders the provenance chip when a widget was authored by an agent. On a MULTI-AGENT + * board the view passes an `agentChip` and this renders the per-agent COLOURED chip + * (short id, full actor on hover, deterministic hue) — the distinguishing affordance of + * SPEC §17.3 (#59). Otherwise it falls back to the plain "AI" chip. + */ +function renderProvenanceChip( + widget: DashboardWidget, + agentChip: AgentChipModel | undefined, +): TemplateResult | typeof nothing { const agentId = dashboardAgentProvenance(widget.createdBy); if (!agentId) { return nothing; } + if (agentChip) { + // The hue drives both text + a translucent fill via a CSS custom property, so the + // stylesheet owns the exact colour math (readable in light + dark). Full actor on + // hover; the visible label is the short id. + const classes = agentChip.dimmed + ? "dashboard-widget__agent dashboard-widget__agent--dimmed" + : "dashboard-widget__agent"; + return html`${agentChip.short}`; + } return html`${displayWidgetTitle(widget.title)} - ${renderProvenanceChip(widget)} ${renderEphemeralBadge(widget)} + ${renderProvenanceChip(widget, props.agentChip)} ${renderEphemeralBadge(widget)} 0 + ? t("dashboard.widget.approvals.scopedTo", { agents: agents.join(", ") }) + : t("dashboard.widget.approvals.scopeAll"); + return html`${t("dashboard.widget.approvals.scopeLabel")}: ${summary}`; +} + /** The per-tool grant + auto-confirm control list for a capability row. */ function renderToolControls(item: PendingApprovalItem): TemplateResult { const tools = item.tools ?? []; @@ -182,6 +201,7 @@ export function renderApprovals( : nothing } ${hasTools ? renderToolControls(item) : nothing} + ${isCapability ? renderScope(item) : nothing} ${ isCapability ? html`