pi-roundtable 0.9.12 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/docs/migration-0.9.md +3 -1
- package/docs/plugins.md +146 -21
- package/examples/fake-surface.test.ts +137 -0
- package/examples/fake-surface.ts +71 -14
- package/package.json +1 -1
- package/src/cli/checks/identity.ts +73 -5
- package/src/core/agents/agent-claim.ts +12 -0
- package/src/core/agents/team-turns.ts +11 -4
- package/src/core/builtin/agent-server.ts +6 -0
- package/src/core/config/access.ts +23 -3
- package/src/core/config/adapters.ts +11 -0
- package/src/core/config/schema.ts +14 -0
- package/src/core/define-roundtable.ts +1 -0
- package/src/core/define.ts +44 -6
- package/src/core/discord/card-audience.ts +104 -7
- package/src/core/discord/discord-owners.ts +19 -13
- package/src/core/discord/owner-card-types.ts +73 -0
- package/src/core/discord/owner-cards.ts +56 -77
- package/src/core/domain/conversation.ts +7 -0
- package/src/core/domain/ports.ts +4 -1
- package/src/core/holds.ts +60 -0
- package/src/core/i18n/cards.ts +3 -0
- package/src/core/identity/access-policy.ts +65 -2
- package/src/core/identity/caching-principal-store.ts +2 -2
- package/src/core/identity/identity-links.ts +96 -0
- package/src/core/identity/identity-plugin.ts +10 -3
- package/src/core/identity/identity-schema.ts +3 -1
- package/src/core/identity/identity-service.ts +5 -3
- package/src/core/identity/principal-store.ts +59 -142
- package/src/core/identity/principal-tables.ts +10 -2
- package/src/core/identity/principal-types.ts +146 -0
- package/src/core/interactions/prompts.ts +47 -3
- package/src/core/modules/schedules/precheck-tools.ts +115 -20
- package/src/core/routing/conversation-turns.ts +4 -1
- package/src/core/runtime/conversation-sessions.ts +8 -0
- package/src/core/runtime/extensions/confirmation-gate-extension.ts +50 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +252 -204
- package/src/core/runtime/extensions/gate-refusals.ts +133 -0
- package/src/core/runtime/extensions/held-calls.ts +91 -0
- package/src/core/runtime/extensions/hold-lookups.ts +108 -0
- package/src/core/runtime/pi-agent-runtime.ts +7 -3
- package/src/core/runtime/prompt-slot.ts +10 -2
- package/src/core/runtime/session-factory.ts +1 -0
- package/src/core/services.ts +11 -0
- package/src/core/sessions.ts +7 -0
- package/src/core/testing/surface-contract-support.ts +194 -0
- package/src/core/testing/surface-contract.ts +54 -120
- package/src/core/tool-turn.ts +5 -1
- package/src/index.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,40 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.10.0] - 2026-10-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `packages/line`: the consumer side of the LINE transport. One subscriber receives typed events (`message`, `edited`, `unsent`, `joined`, `left`, `memberJoined`, `memberLeft`) with opaque mirror refs stored one to many, tombstones and `keep(ref)` that take an unsent message down in every race except a crash between a post and its `keep`, message content held in memory under a size cap, and a retention sweeper. See the package changelog.
|
|
13
|
+
|
|
14
|
+
- `packages/line`: the sending side. An outbox (`LINE.outbox.enqueue`) with per-chat and per-sender coalescing into pushes of at most five, validated sender names and icons, a retry key stored before the first attempt and reused for 24 hours, backoff, dead letters with LINE's code and a quota gate that counts group members; the digest-gated join notice sent as a Reply; the `line` chat surface; optional router delivery (`route`, off by default) and a sender icon route. See the package changelog.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Breaking (`pi-roundtable-line`, published in 0.9.13):** `LINE.attachProcessor` is removed in favour of `LINE.subscribe`, without a shim. The next lockstep release is 0.10.0.
|
|
19
|
+
|
|
20
|
+
## [0.9.13] - 2026-10-13
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `discord.agentSpeakers: "everyone" | "owners"` (`DiscordConfig`; the same key in `discord({ ... })`). The default `"everyone"` is the 0.9 behavior: every speaker the access rules admit is answered in the agents' and groups' channels. With `"owners"` only owner-tier speakers are, and a message from an admin or member there starts no turn and gets no reply; scheduled turns and delegated-work reports started for a non-owner are skipped too, so an admin's existing schedules in those channels stop running. An outside webhook's post in an agent's channel is still a report turn for the agent. `roundtable doctor` (the access check) now warns when `access.admins` or `access.members` admits Discord speakers and `agentSpeakers` is not `"owners"`.
|
|
25
|
+
- `access.selfLink: { providers: ["discord"] }` and the `IDENTITY_LINKS` service (`IdentityLinks`, `SelfLinkRules`): a plugin that proved who someone is can link the account that person controls to their principal with `link(principalId, "<provider>:<subject>")` and take it back with `unlink(identity)`. The `identity` plugin provides it only when the configuration lists the providers, so `IDENTITY` stays read-only and a host without `selfLink` has no such service. `link` refuses, and changes nothing for, a provider that is not listed, the system principal, a principal that holds the owner role or does not exist, and an identity linked to another principal; linking it again to the same principal is a no-op that keeps its source. `unlink` removes only links of the new source `self` (`LinkSource` gains `"self"`, printed by `roundtable principal show`); the configuration's, a plugin's, the CLI's, a first contact's and a 0.8 claim's are refused. `token` and `legacy` cannot be listed as providers. Logs carry the principal id and the identity only. The migration `identity/principals-self-links` widens the `principal_identities.source` check (the earlier `principals-plugin-links` now accepts `self` too, so rerunning migrations on a database holding self links works); the owner refusal is made inside the link's transaction, so a freshly granted owner role is seen, while an owner role granted after a self link leaves that link; `unlink` answers false only when no self link was removed and throws when another source holds the identity; a bare `oidc` is refused as a provider (write `oidc:<base64url issuer>`); `PrincipalStore.unlink` takes an optional `source`.
|
|
26
|
+
- `access.provisioning` takes a map from surface to mode as well as one word: `{ web: "admitted", discord: "linked" }` admits a person at first contact on the web and serves only already linked identities on Discord. With `members: { everyone: ["web", "discord"] }`, a Discord id linked to a principal is served at member, while an unlinked one is refused and never recorded as a principal or an identity; web admits everyone it admitted before. A surface the map leaves out is `linked` (fail closed, so a misspelled surface name cannot admit everyone), and the string form (`"admitted"`, `"linked"`) means what it did. `roundtable doctor` warns about a map key that is not a surface the host talks through, and prints the effective mode in the access line (`provisioning admitted`, or one `<surface> <mode>` per surface for a map). Exported types: `Provisioning` and `ProvisioningRule`.
|
|
27
|
+
- **Approver sets.** A tool's `defineTool({ approvers(args, turn) })` (and `HoldRule.approversInTurn(tool, input, context, turn)`, with `holdChain(rules).approversInTurn`) names the principal ids that may approve a held call **instead of the speaker** whose turn made it. It is looked up in the turn that makes the call, with `hold`'s wait (`HOLD_DESCRIBE_TIMEOUT_MS`) and `turn.signal`; a rejection, a throw or a timeout names nobody, so only the owners approve, and where several rules name approvers only the principals every one names count. The gate passes them to `Prompts.confirm` as the new seventh argument `options` (`ApprovalOptions`: `approvers`, `approvedBy`) and keeps them with the held call as `HeldCall.approverIds`, in the same stored record as `minTier` (no schema change), so a message that confirms the call after a restart honors them. Each named principal needs the call's tier when they answer, rechecked then, and owners may still approve where the conversation is shared; an empty set leaves the owners alone. Only named principals and owners approve such a call, in a private conversation too: its person alone answers there, so the call is theirs only when they are named or an owner, and otherwise its card is `unavailable` at once and a message confirming it is refused, whatever the surface (Discord DMs, the web chat's prompts, a remote MCP conversation). A call that names no approvers behaves as before, and the web chat is unchanged for every existing tool. A confirming message in an agent's channel (`TeamTurns`, group rounds) is accepted, call by call, from an owner, or from a named principal where the call names some, or else from the speaker whose turn held it; and only when their tier now holds every held call.
|
|
28
|
+
- **`ToolTurn.approvedBy`** (`ApprovedBy`: `{ principalId, speakerId, name }`, also `SessionContext.approvedBy(toolCallId)`, optional on the type so a context built before this still type-checks): who approved the held call a tool's `run` is running, whether they pressed its card in the turn, pressed it after the turn stopped waiting (`LateAnswer.approvedBy`), or confirmed it by message. Undefined for a call that was not held and for a call a surface released without saying who (the web chat reports none); a tool that must know the approver fails closed. `Prompts.confirm`'s `options.approvedBy(by)` is how a surface reports it.
|
|
29
|
+
- Discord cards for a call that names approvers mention every Discord identity of every named principal and are answered by them (at the tier the call needs, checked when they press, on an identity still theirs), the owners, and not the speaker unless named; the refusal line says so (`cardNamedApproversRefusal`, in English and Traditional Chinese). With no named principal on Discord the card is the owners'.
|
|
30
|
+
- Surface contract: `SurfaceContractSubject.approver` (`SurfaceContractAnswerer` with a `principalId`) and `privateOnly` add two checks to `describeSurfaceContract` / `checkSurfaceContract`: an approval that names the approver is answered by them and not by the person or another person, and a private conversation is answered by its person alone, whoever else the call names. A surface must now report the named approver who answered through `ApprovalOptions.approvedBy`, which the first check requires. Both are skipped without `approver`; `privateOnly` skips the first for a surface with no shared conversation. `FakeSurface` in `examples/fake-surface.ts` implements both, and shows a private conversation's call that names others to its person when they are named or an owner.
|
|
31
|
+
- `packages/line` (`pi-roundtable-line`), a new lockstep workspace for a LINE Messaging API transport. Its first slice is intake: stateless channel access tokens, a signed webhook that persists events before it answers, an allowlist of chats, and a durable inbox worker. It needs a manual first publication; see `.github/PACKAGE-RELEASES.md`.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- A turn started as an approval (`TurnRequest.confirmed`, `context.turns.run({ confirmed })`, a remote MCP conversation's confirming message) releases only the held calls its speaker may approve: a call that named approvers is released only when the speaker is one of them or an owner, and otherwise stays held for those who may, whoever asked for the release. In a private conversation a call that names approvers the person is not among, and is not an owner of, is held without opening a card. A shared conversation's approval of such a call that does not say who gave it, or gives a principal that is neither named nor an owner, leaves the call held. When a call that names approvers is held and the person whose turn made it cannot approve it (a private conversation, or a shared one whose card expired or that has no prompts), the model is told who may approve it, by name and never by id, that this conversation cannot, not to retry it and not to ask that person to confirm; in a shared conversation it is told to ask a named approver or an owner to confirm in the channel. Calls a partial release kept held do not carry their asker over to what is held afterwards: a call held later in that turn belongs to its own speaker and is good for 24 hours from then. The held set has one time, so the calls kept with it are good for 24 hours from then too: each such turn extends them, and any turn that is not a confirmation still clears them. The model's list of who may approve skips disabled principals and says when some named ones could not be read. A tool's `approvers` name approvers for its own calls only, so a precheck script's call with computed arguments to another tool stays its speaker's to approve.
|
|
36
|
+
- `ApprovedBy` gains `owner?: true`, set when the approver acted as an owner; Discord and the in-memory surfaces set it. `PromptScope.approvers` is not part of the API: the names reach a surface only through `ApprovalOptions.approvers`.
|
|
37
|
+
- Saving a schedule whose precheck script calls a tool that names approvers is approved only by the principals those calls name, or by the owners alone where they cannot be read, or where a call's arguments are computed by the script and a rule may name approvers for its tool: `precheckScriptHoldRule` gained `approversInTurn`. `HoldRule` takes an optional `mayName(tool)`, and `HoldCheck` (and `holdChain`'s) gains `mayName`, which say whether a rule with `approversInTurn` may name approvers for some call of a tool whatever its input; a rule without it is taken to name approvers for every tool.
|
|
38
|
+
- `ConfirmationGate.beginTurn` accepts the speaker's `name` (optional), which names the approver of calls their message confirmed. The gate's internals moved to `held-calls.ts` and `confirmation-gate-extension.ts`; `canonicalJson`, `confirmedTurnText`, `unexpired`, `CONFIRMATION_TTL_MS` and `confirmationGateExtension` are still exported from `confirmation-gate.ts`.
|
|
39
|
+
- **Before downgrading** to a version without approver sets, clear the pending held calls that carry `approverIds` (approve, refuse or let them expire): an older version ignores the names and lets the speaker and the owners approve them.
|
|
40
|
+
- Additive: nothing changes for a tool, rule, surface or stored row that does not use approvers. Rows written by a version that does not know `approverIds` name nobody, which leaves the speaker as the approver, and a version that does not know it ignores the names when it reads such a row and drops them when it saves it back, so the speaker and the owners approve it, as for any held call.
|
|
41
|
+
|
|
8
42
|
## [0.9.12] - 2026-10-11
|
|
9
43
|
|
|
10
44
|
### Added
|
package/docs/migration-0.9.md
CHANGED
|
@@ -45,7 +45,7 @@ Use `roundtable principal list` and `show` to inspect principals and copy identi
|
|
|
45
45
|
`link <principal> <identity>` links another account to the same person's memory; `grant`, `revoke`, `disable`, and `enable` manage lasting roles and admission.
|
|
46
46
|
CLI changes can take up to 30 seconds to reach a running host's identity cache.
|
|
47
47
|
Only configuration and CLI grants confer owner; IdP roles never do.
|
|
48
|
-
`provisioning: "admitted"` creates a principal for a newly admitted person; `"linked"` accepts only existing links and does not claim old ids.
|
|
48
|
+
`provisioning: "admitted"` creates a principal for a newly admitted person; `"linked"` accepts only existing links and does not claim old ids; a map such as `{ web: "admitted", discord: "linked" }` chooses per surface, and a surface the map does not name is `linked` (fail closed). The one-word form and an omitted field stay `admitted`.
|
|
49
49
|
|
|
50
50
|
## Data migration and conversation ownership
|
|
51
51
|
|
|
@@ -150,7 +150,9 @@ A conversation holding only a guest's private turns admits that guest, not the o
|
|
|
150
150
|
Other providers and private conversations are unaffected by this guard.
|
|
151
151
|
|
|
152
152
|
In the agents' channels, `discord.agentMemory: "owners"` (default `"everyone"`) keeps a speaker below the owner tier out of memory: that turn has no memory in its prompt, no memory tools, and no memory-loading workers, so its reader record carries no private memory and a later owner or SYSTEM turn is not refused because of it.
|
|
153
|
+
|
|
153
154
|
The reverse stays: once an owner's turn has loaded memory, a guest's bridge turn in the same conversation is refused until the conversation starts fresh.
|
|
155
|
+
To keep admitted members from talking to the agents at all, set `discord.agentSpeakers: "owners"` (default `"everyone"`): only owners are answered in the agents' and groups' channels, and a webhook's report in an agent's channel is unchanged.
|
|
154
156
|
|
|
155
157
|
Admission of another person in configuration or stored roles is only a startup warning, and `roundtable doctor` warns rather than fails for that risk.
|
|
156
158
|
A guest admitted elsewhere but absent from this conversation does not block the owner's turns.
|
package/docs/plugins.md
CHANGED
|
@@ -190,6 +190,7 @@ The built-in plugins provide these, from the main entry:
|
|
|
190
190
|
| Key | Port | Provided by | What it is |
|
|
191
191
|
|---|---|---|---|
|
|
192
192
|
| `IDENTITY` | `IdentityService` | `identity` | Who the host serves, read-only: `resolve(facts, { conversation }?)` gives the `Speaker` with its `principalId` behind a surface's `ActorFacts`, or undefined; `principal(id)`, `list()`, `identities(principalId)`, `roles(principalId)`, `tierOf(principalId)` (the lasting roles' tier), `speakerFor(principalId, tier?)` for a turn on someone's behalf, `owners()`, and `principalOf(identity)`, the principal an identity written `<provider>:<subject>` is linked to, such as one a plugin declares in `identities`. Principals are written only by the configuration and `roundtable principal`. `SYSTEM_PRINCIPAL` is the host's own, which no plugin speaks as |
|
|
193
|
+
| `IDENTITY_LINKS` | `IdentityLinks` | `identity`, only with `access.selfLink` | Lets a plugin link a person's own account to their principal, which `IDENTITY` cannot: `link(principalId, identity)` and `unlink(identity)`, identities written `<provider>:<subject>`. See [Self-linking](#self-linking). Absent unless the configuration lists the providers; read it with `find` |
|
|
193
194
|
| `CONVERSATIONS` | `ConversationRegistry` | `conversations` | The conversations run through `context.turns`: `register`, `get(key)`, `list({ principal }?)`, `setTitle(key, title)`, and `adopt(key, principalId)` only for migrating a conversation recorded before 0.9; each a `ConversationRecord` of `key`, `surface`, `kind`, `visibility` (`"private"` or `"shared"`), `principalId?`, `title?`, `createdAt`, `lastActiveAt` |
|
|
194
195
|
| `RUNTIME` | `AgentRuntime` | `runtime` | The runtime every conversation turn runs on, the agent server's and `context.turns`': the `runtime` slot's when a plugin fills it, Pi's otherwise |
|
|
195
196
|
| `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on (the same one `RUNTIME` provides), `approvals` (whether the owner's reply approves held actions), and `avatars` (`AvatarStudio`) |
|
|
@@ -368,6 +369,8 @@ It does not make the guest's turn safe to run after the owner's: once the owner'
|
|
|
368
369
|
It applies to the agents' channels and groups only; other conversations keep deciding by their persona.
|
|
369
370
|
The default `"everyone"` is the 0.8 and 0.9 behavior.
|
|
370
371
|
|
|
372
|
+
`discord: { agentSpeakers: "owners" }` (or the same key in `discord({ ... })`) limits who the agents answer: in the agents' and groups' channels only an owner-tier speaker starts a turn, and a message from an admin or member gets no reply and no turn. The default `"everyone"` answers every speaker the access rules admit. An outside webhook's post in an agent's channel is a report turn either way. Scheduled turns and delegated-work reports started for a non-owner are skipped the same way. Under `"owners"` a member's message can still reach an owner's turn as channel context. Use it on a host whose `access.admins` or `access.members` admit Discord: a non-owner turn runs in the agent's home session, shared with the owners, so it can read earlier owner conversation and shell output and leaves history that owner turns inherit. `roundtable doctor` warns about that combination.
|
|
373
|
+
|
|
371
374
|
## Principals and access
|
|
372
375
|
|
|
373
376
|
A principal is the person the host serves; an identity is an account or credential linked to that person.
|
|
@@ -381,8 +384,25 @@ Identities are `<provider>:<subject>` (OIDC uses `oidc:<base64url issuer>:<subje
|
|
|
381
384
|
The primary owner is the first owner, with an explicit `principal` and, on Discord, a Discord identity; Discord administration still acts with that person's permissions.
|
|
382
385
|
Owner is granted only through configuration or the principal CLI, never an IdP role.
|
|
383
386
|
`provisioning: "admitted"` is the default; `"linked"` admits only already linked identities.
|
|
387
|
+
A map gives the mode per surface, such as `provisioning: { web: "admitted", discord: "linked" }`: with `members.everyone: ["web", "discord"]`, a Discord id someone linked is served at member, an unlinked one is refused and not recorded, and web admits as before. A surface the map leaves out is `linked`, so a misspelled name cannot admit everyone; the one-word form and an omitted field stay `admitted`. `roundtable doctor` warns about a map key that is not a known surface and prints the effective mode.
|
|
384
388
|
A disabled principal is refused, and CLI changes reach a running host within the identity cache's 30 seconds.
|
|
385
389
|
|
|
390
|
+
### Self-linking
|
|
391
|
+
|
|
392
|
+
`access: { selfLink: { providers: ["discord"] } }` lets a plugin link the account a person controls to their principal.
|
|
393
|
+
A plugin that proved who someone is, such as one that checked a one-time code a signed-in person read out, calls `IDENTITY_LINKS.link(principalId, "discord:<user id>")`; the host provides the service only when `selfLink` is configured, so without it `services.find(IDENTITY_LINKS)` is undefined and `services.get` explains how to switch it on.
|
|
394
|
+
The plugin proves the person; the host only checks the rules below, so it must not link an identity it has not verified.
|
|
395
|
+
`token` and `legacy` cannot be listed: they are credentials and carried-over ids, not a person's own account. An OIDC provider is listed in its full form `oidc:<base64url issuer>`; a bare `oidc` is refused.
|
|
396
|
+
|
|
397
|
+
`link` throws `IdentityError`, and changes nothing, for a provider `selfLink` does not list, text that is no identity, the system principal, a principal that does not exist, a principal holding the owner role (owners' identities are linked in `access.owners` or with `roundtable principal link`), and an identity already linked to another principal, whoever linked it.
|
|
398
|
+
The owner check is part of the link's own database transaction, so a role another process granted a moment ago is seen; an owner role granted after a self link was made leaves that link in place.
|
|
399
|
+
Linking the identity again to the same principal changes nothing and keeps the source it already has.
|
|
400
|
+
The link has the source `self`, which `roundtable principal show` and `list` print.
|
|
401
|
+
`unlink(identity)` removes only a `self` link and answers true when it removed one, false when there was no self link to remove (the identity was linked to no one, or its link went away meanwhile); a link the configuration, a plugin, the CLI, a first contact, or a 0.8 claim made throws `IdentityError`, and a link another source took over meanwhile is kept.
|
|
402
|
+
The host logs the principal id and the identity on each, never a name.
|
|
403
|
+
A plugin that replaces `IDENTITY` must replace `IDENTITY_LINKS` too when `selfLink` is configured, as the host refuses a partial replacement.
|
|
404
|
+
A link made here is served at once by this process; another process sees it within the identity cache's 30 seconds.
|
|
405
|
+
|
|
386
406
|
Surfaces report verified `ActorFacts`, not authority.
|
|
387
407
|
The router resolves them through `IDENTITY` before admission and overwrites a surface's own `message.speaker`.
|
|
388
408
|
A claim must ignore a message without a resolved speaker and run an admitted message as that speaker, rather than reconstructing one at an assumed tier.
|
|
@@ -793,10 +813,46 @@ The gate awaits the description before it posts the card, so the card's bold lin
|
|
|
793
813
|
A rule of `holdRules` does the same with `describeInTurn(tool, input, context, turn)`, which may return a promise and which the gate asks instead of `describe` in a turn.
|
|
794
814
|
`describe` is still required and is what a host without a turn sees. `holdChain(rules).inTurn(tool, input, context, turn)` asks the rules as a turn does, in order: each rule's `describeInTurn`, or its `describe` for a rule without one.
|
|
795
815
|
|
|
796
|
-
A rule whose verdict depends on the input, such as one that holds only a `delete` action, can also answer `mayHold(tool)`: whether it may hold some call of that tool.
|
|
816
|
+
A rule whose verdict depends on the input, such as one that holds only a `delete` action, can also answer `mayHold(tool)`: whether it may hold some call of that tool. A rule with `approversInTurn` whose answer depends on the input can likewise answer `mayName(tool)`: whether it may name approvers for some call of that tool; without it the rule is taken to name approvers for every tool.
|
|
797
817
|
It is asked when the input is not known yet, as for a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s call whose arguments are computed when it runs; a rule without it is judged by `describe` with an empty input.
|
|
798
818
|
A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it. A card is answered, and a confirming message accepted, from the speaker whose turn held the call, when their tier is at least `minTier`, and from the owners where the conversation is shared ([who answers a prompt](#who-answers-a-prompt-promptscope)); nobody else in the channel may approve it. The held call records that speaker as `PendingConfirmation.speakerId` and their principal as `principalId`, so a confirming message from another identity of theirs counts after a restart too; a call held without them is the owners' to approve, and one held before 0.9, without a principal, is matched by `speakerId`.
|
|
799
819
|
|
|
820
|
+
#### A hold that names its approvers
|
|
821
|
+
|
|
822
|
+
A held call is approved by the speaker whose turn made it, or by an owner where the conversation is shared. A tool whose call stands for someone else, such as a message sent under another person's name, can name who approves it instead:
|
|
823
|
+
|
|
824
|
+
```ts
|
|
825
|
+
defineTool({
|
|
826
|
+
name: "customer_message_send",
|
|
827
|
+
description: "Send a message to a customer.",
|
|
828
|
+
parameters: Type.Object({ customer: Type.String(), text: Type.String() }),
|
|
829
|
+
minTier: "member",
|
|
830
|
+
hold: ({ customer }) => `Send a message to ${customer}`,
|
|
831
|
+
approvers: async ({ customer }, turn) => owners.ofCustomer(customer, turn.signal),
|
|
832
|
+
run: async ({ customer, text }, turn) => {
|
|
833
|
+
const by = turn.approvedBy;
|
|
834
|
+
// The runtime already releases the call only for a named principal or an owner; a tool that
|
|
835
|
+
// sends under a name checks again, against its own set, and names the sender from the
|
|
836
|
+
// principal, never from `by.name`, which is only what the person's account calls itself.
|
|
837
|
+
if (!by || !(by.owner || (await owners.ofCustomer(customer, turn.signal)).includes(by.principalId)))
|
|
838
|
+
throw new ToolRefusal("the message was not approved by one of the customer's approvers");
|
|
839
|
+
return messages.send(customer, text, await people.displayName(by.principalId));
|
|
840
|
+
},
|
|
841
|
+
});
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
`approvers(args, turn)` returns the principal ids that may approve this call. It is asked, as `hold` is, in the turn that makes the call, with the same `ToolTurn`, the same wait (`HOLD_DESCRIBE_TIMEOUT_MS`) and the same `turn.signal`, once for each call a rule holds; it must change nothing. A rule of `holdRules` does the same with `approversInTurn(tool, input, context, turn)`, and `holdChain(rules).approversInTurn(...)` leaves the principals every rule that names approvers names.
|
|
845
|
+
|
|
846
|
+
- The named principals approve the call **instead of the speaker**: the speaker whose turn made the call may not answer its card or confirm it by message unless they are named. Owners may still. This holds in every conversation, private or shared.
|
|
847
|
+
- Each named principal needs the call's tier (its tool's tier, or the `approvalTier` a rule gives it) when they answer, checked again then, and is accepted only on an identity of theirs the surface knows.
|
|
848
|
+
- An empty list leaves the owners alone. A rejection, a throw or a timeout of the lookup does the same: it never widens who may approve.
|
|
849
|
+
- In a [private conversation](#who-answers-a-prompt-promptscope) the person alone could answer, so the call is theirs only when they are named or an owner. Otherwise its card resolves `unavailable` at once, shown to no one, and a message confirming it is refused: the call stays held for the people who may approve it. A call above their tier resolves `unavailable` as without names.
|
|
850
|
+
- A held call that the speaker cannot approve, in a private conversation or in a shared one whose card expired or that has no prompts, is reported to the model as such: who may approve it by name (never by id), that this conversation cannot, and not to retry it or ask the speaker to confirm; in a shared conversation it asks a named approver or an owner to confirm in the channel. The names come from the runtime's principal lookup; where they cannot be read, the text says "the people named for it".
|
|
851
|
+
- The names are kept with the held call as `HeldCall.approverIds`, in the same record as `minTier`, so a message that confirms it after a restart is judged by them: from the speaker or an owner, as before, only when the call names none; otherwise from a named principal or an owner, and only when their tier now holds every held call. The runtime checks this whenever a turn is begun as an approval, so a plugin's own claim that starts a turn with `confirmed` cannot release a call whose approvers exclude its speaker: that call stays held, and the calls the speaker may approve run. A confirming message that holds several calls needs a person every one of them accepts. A row written before this, or by a version that does not know it, names nobody, which leaves the speaker as the approver.
|
|
852
|
+
- A call whose rules name no approvers is unchanged, and so is a call held where no turn is known, such as a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s call when it runs. Saving a script that calls a tool that names approvers is held too: the schedule tool's call is approved by the principals every such call in the script names, or by the owners alone where they cannot be read, or where the script computes a call's arguments and a rule may name approvers for its tool (`mayName`), since the names are not known until it runs.
|
|
853
|
+
|
|
854
|
+
`run` learns who approved: `turn.approvedBy` is `{ principalId, speakerId, name, owner? }` of the person who pressed the card, or whose message confirmed the call, and the same for a card answered after the turn stopped waiting. It is undefined for a call that was not held, and for a call a surface released without saying who (the web chat does not, for a call that names no approvers); a tool that must know the approver fails closed when it is undefined. `name` is the name the person's identity goes by on the surface, which they may change: look the sender up by `principalId`. `owner` is true when they approved as an owner. A surface reports it through `ApprovalOptions.approvedBy` of `Prompts.confirm`, and `LateAnswer.approvedBy`; for a call that names approvers it must, because the runtime accepts an approval from a named principal or an `owner` only: in a shared conversation an approval that does not say who gave it leaves the call held.
|
|
855
|
+
|
|
800
856
|
#### The shell rule
|
|
801
857
|
|
|
802
858
|
`shellHoldRule` in `pi-roundtable/kit` judges the agents' `bash`, `write` and `edit` calls; the agent server links it, and a session without a workspace has no shell.
|
|
@@ -1344,7 +1400,7 @@ An `AgentRuntime` has these methods:
|
|
|
1344
1400
|
| `preflight?()` | Runs in the host's preflight, before anything starts; a throw stops the boot |
|
|
1345
1401
|
| `dispose?()` | Runs when the host stops the runtime plugin's `runtime` service |
|
|
1346
1402
|
|
|
1347
|
-
A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker` (required: the person the turn is for, at whose tier it runs; the built-in runtime refuses a request without one with an `AgentRunError` before asking the model, and a plugin that starts a turn on someone's behalf gets theirs from `IDENTITY.speakerFor`), `conversation` (`TurnConversation`: `private` to a `principalId`, or `shared`, as `context.turns` records it; shared when absent), flags (`steerable`, `interactive`, `confirmed
|
|
1403
|
+
A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker` (required: the person the turn is for, at whose tier it runs; the built-in runtime refuses a request without one with an `AgentRunError` before asking the model, and a plugin that starts a turn on someone's behalf gets theirs from `IDENTITY.speakerFor`), `conversation` (`TurnConversation`: `private` to a `principalId`, or `shared`, as `context.turns` records it; shared when absent), flags (`steerable`, `interactive`, `confirmed`: the speaker's message approved the conversation's held calls, which the runtime releases for the speaker, except those that [name approvers](#a-hold-that-names-its-approvers) the speaker is neither among nor an owner of, which stay held), and `interim`, where the turn may post the text it writes before its final answer ([interim text](#interim-text-what-a-turn-writes-before-its-final-answer)).
|
|
1348
1404
|
An agent's turn also has `agent`, the agent's scope, whose `session` is the conversation's key.
|
|
1349
1405
|
Other turns use `kind` to name the conversation's persona, defaulting to `"owner"` when absent.
|
|
1350
1406
|
|
|
@@ -2033,7 +2089,7 @@ Call `context.turns.run(input)` inside your claim's queue task to run a turn in
|
|
|
2033
2089
|
It shows typing and the stop control on the channel's surface, runs the turn, and emits `turnStarted` and `turnEnded` with the turn's `kind`.
|
|
2034
2090
|
It catches runtime errors as failed results and posts the answer, failure notice, or stopped notice through the surface.
|
|
2035
2091
|
Supply `reply(result)` to handle the reply yourself.
|
|
2036
|
-
`input` has `channel`, `kind`, `text`, `speaker`, and optionally `origin` (where the request came from, as your claim's transport knows it, such as a connection's browser origin; tools read it as `turn.origin`, so never fill it from message text), `client` (a `ClientPort` for what the client that started the turn can do, which tools read as `turn.client`; only your transport provides one), `attachments`, `selection` (by default the plugins' `agentSelection`), `steerable`, `interactive`, `confirmed
|
|
2092
|
+
`input` has `channel`, `kind`, `text`, `speaker`, and optionally `origin` (where the request came from, as your claim's transport knows it, such as a connection's browser origin; tools read it as `turn.origin`, so never fill it from message text), `client` (a `ClientPort` for what the client that started the turn can do, which tools read as `turn.client`; only your transport provides one), `attachments`, `selection` (by default the plugins' `agentSelection`), `steerable`, `interactive`, `confirmed` (the speaker's message approved the held calls; calls that [name approvers](#a-hold-that-names-its-approvers) the speaker is neither among nor an owner of stay held), `reply`, and `conversation`.
|
|
2037
2093
|
It rejects with `NotLinkedError` during `setup`, and with a `PluginError` on a host whose runtime plugin has not provided a runtime.
|
|
2038
2094
|
|
|
2039
2095
|
Before each turn runs, `context.turns.run` records its conversation in the host's registry, `CONVERSATIONS`: at the first turn its key, surface, kind, visibility, owner, and title, and at every later turn only that it was active.
|
|
@@ -2473,6 +2529,10 @@ A surface's prompts keep these rules:
|
|
|
2473
2529
|
- Where it escalates to no one, nobody else answers, and an approval above the speaker's tier resolves `unavailable` at once, shown to no one: the call stays held. (`expired` is for a card that was shown and went unanswered; a surface that cannot tell the two may answer `expired` for both.)
|
|
2474
2530
|
- Anyone else is refused, even at the same tier as the speaker.
|
|
2475
2531
|
- Without a scope, as for a turn with no speaker, the prompts are the owners'.
|
|
2532
|
+
- An approval that names [approvers](#a-hold-that-names-its-approvers) (`ApprovalOptions.approvers`, which `confirm` receives beside the scope) is theirs **instead of the speaker's**, where the scope escalates to the owners: each named principal answers on any of their identities the surface knows, at the tier it needs, checked again when they answer; the speaker does not answer it unless named, and an owner still does. When none of the named has an identity on the surface, or none is named, it is the owners' alone. Where the scope escalates to no one, the person alone answers, and only when they are named or an owner: for anyone else the approval resolves `unavailable` at once, shown to no one. The runtime also refuses such a call before it asks, whatever the surface does.
|
|
2533
|
+
|
|
2534
|
+
On Discord a card for named approvers mentions every Discord identity of every named principal that has one, in a thread, and refuses anyone else with a line that says it is for the people it names and the owners.
|
|
2535
|
+
The web chat shows prompts to the conversation's person only. The runtime never asks it about a call that names approvers the person is neither among nor an owner of, and the person is the approver of one that names them.
|
|
2476
2536
|
|
|
2477
2537
|
On Discord the owners are every owner with a Discord identity, and the primary owner always; a card in a thread mentions the identity that spoke when it is theirs and one of the principal's Discord identities, every Discord identity of the principal when the speaker is none of them, and the owners when it goes to them, so a single owner's cards read as they did in 0.8.
|
|
2478
2538
|
A principal with no Discord identity, such as the reporter of a background report, answers nothing there, so their card goes to the owners; so does an owner-tier call of a turn whose tier defaulted to owner when its principal is not an owner.
|
|
@@ -2480,15 +2540,16 @@ The web chat shows prompts to the conversation's person only: its owners are not
|
|
|
2480
2540
|
|
|
2481
2541
|
#### An approval as data: `ApprovalDetails`
|
|
2482
2542
|
|
|
2483
|
-
`confirm(title, message, signal?, minTier?, wait?, details?)` takes the approval twice: `message` is the card as markdown text, and the optional `details` (`ApprovalDetails`: `{ action, tool, input, files? }`) is the same held call as data, with its whole input (the web chat cuts it only past 256 KiB and says so with `truncated`), and each file it sends by path as `{ path, bytes? }`.
|
|
2543
|
+
`confirm(title, message, signal?, minTier?, wait?, details?, options?)` takes the approval twice: `message` is the card as markdown text, and the optional `details` (`ApprovalDetails`: `{ action, tool, input, files? }`) is the same held call as data, with its whole input (the web chat cuts it only past 256 KiB and says so with `truncated`), and each file it sends by path as `{ path, bytes? }`.
|
|
2484
2544
|
The confirmation gate and a coding worker's cards pass both.
|
|
2545
|
+
`options` (`ApprovalOptions`) says who else may approve the call and hears who did: `approvers`, the principal ids that approve it instead of the speaker (see [who answers a prompt](#who-answers-a-prompt-promptscope)), and `approvedBy(by)`, which a surface that knows who answered calls with `{ principalId, speakerId, name, owner? }` (`ApprovedBy`; `owner` when they approved as an owner) before `confirm` resolves `approved`. A surface may ignore both for a call that names no approvers. For one that does, the runtime accepts the approval only from a surface that calls `approvedBy` with a named principal or an `owner`, so a surface that cannot say who answered must not offer such a call to a shared conversation.
|
|
2485
2546
|
A surface that shows text, such as Discord, uses `message` and ignores `details`; the web chat sends `details` to the client as the prompt frame's `approval`, beside the unchanged `title` and `message`.
|
|
2486
2547
|
A surface or a `Prompts` of your own may ignore it: nothing requires reading it.
|
|
2487
2548
|
`confirm` resolves `approved`, `declined`, `expired` (a card was shown and went unanswered), `unavailable` (no card could be shown: no one may approve the call, the speaker is below its tier, or the card could not be posted), `cancelled` (the turn stopped), or `pending` (with a `wait`). Both `expired` and `unavailable` leave the call held for the owner's next message; the turn's progress reports them as `refused: "expired"` and `refused: "held"`.
|
|
2488
2549
|
|
|
2489
2550
|
#### A prompt that outlives its turn: `PromptWait`
|
|
2490
2551
|
|
|
2491
|
-
`confirm(title, message, signal?, minTier?, wait?)` and `ask(title, question, signal?, wait?)` take an optional `PromptWait`, whose `late(answer)` hears a `LateAnswer` (`{ kind: "approval", approved, by }` or `{ kind: "question", answer, by }`, `by` naming who answered) and returns the text of the turn that tells the conversation, or undefined for none.
|
|
2552
|
+
`confirm(title, message, signal?, minTier?, wait?)` and `ask(title, question, signal?, wait?)` take an optional `PromptWait`, whose `late(answer)` hears a `LateAnswer` (`{ kind: "approval", approved, by, approvedBy? }`, `approvedBy` naming the approver where the surface knows them, or `{ kind: "question", answer, by }`, `by` naming who answered) and returns the text of the turn that tells the conversation, or undefined for none.
|
|
2492
2553
|
Given one, a surface may stop the turn's wait after a grace period: `confirm` then resolves `pending` and `ask` resolves `"pending"`, the prompt stays open, and when it is answered later the surface starts a turn in the prompt's conversation with that text, as a message from whoever answered, so the conversation's claim answers it as theirs.
|
|
2493
2554
|
Without one, a prompt waits for its answer, or for its turn's stop; a surface that cannot start a turn ignores `wait` and waits too.
|
|
2494
2555
|
A stop cancels a prompt only while its turn still waits on it; after that the prompt belongs to the conversation.
|
|
@@ -2528,6 +2589,7 @@ Its plugin adds a claim that answers through `context.surfaces`:
|
|
|
2528
2589
|
```ts
|
|
2529
2590
|
import {
|
|
2530
2591
|
type Approval,
|
|
2592
|
+
type ApprovedBy,
|
|
2531
2593
|
type ChannelKey,
|
|
2532
2594
|
type ChatSurface,
|
|
2533
2595
|
definePlugin,
|
|
@@ -2537,6 +2599,7 @@ import {
|
|
|
2537
2599
|
type Prompts,
|
|
2538
2600
|
parseChannelKey,
|
|
2539
2601
|
TIERS,
|
|
2602
|
+
type Tier,
|
|
2540
2603
|
} from "pi-roundtable";
|
|
2541
2604
|
|
|
2542
2605
|
/** A prompt the surface showed, open until someone it is for answers it. */
|
|
@@ -2547,6 +2610,18 @@ interface Asked {
|
|
|
2547
2610
|
open: boolean;
|
|
2548
2611
|
}
|
|
2549
2612
|
|
|
2613
|
+
/** Who may answer an open prompt and what happens when they do. */
|
|
2614
|
+
interface Answering {
|
|
2615
|
+
/** The principals who may answer it: the speaker, or the approvers the call named. */
|
|
2616
|
+
principals: readonly string[];
|
|
2617
|
+
owners: boolean;
|
|
2618
|
+
/** The lowest tier an answer needs, when the principals are the approvers'. */
|
|
2619
|
+
minTier?: Tier;
|
|
2620
|
+
/** Hears who approved it. */
|
|
2621
|
+
approved?(by: ApprovedBy): void;
|
|
2622
|
+
settle(answer: Approval): void;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2550
2625
|
/**
|
|
2551
2626
|
* A chat surface connects the host to one chat network. This one is an in-memory chat: its
|
|
2552
2627
|
* channels are the keys that start with `fake:`, and it records what the host asks of it. A real
|
|
@@ -2560,10 +2635,7 @@ export class FakeSurface implements ChatSurface {
|
|
|
2560
2635
|
readonly stops: string[] = [];
|
|
2561
2636
|
readonly asked: Asked[] = [];
|
|
2562
2637
|
/** Who may answer each open prompt, and how it settles. */
|
|
2563
|
-
readonly #answering = new Map<
|
|
2564
|
-
string,
|
|
2565
|
-
{ principalId?: string; owners: boolean; settle(answer: Approval): void }
|
|
2566
|
-
>();
|
|
2638
|
+
readonly #answering = new Map<string, Answering>();
|
|
2567
2639
|
#deliver: ((message: InboundMessage) => void) | undefined;
|
|
2568
2640
|
|
|
2569
2641
|
/** The host hands over its router; every message the network reports goes to it. */
|
|
@@ -2609,42 +2681,88 @@ export class FakeSurface implements ChatSurface {
|
|
|
2609
2681
|
* How a turn asks to approve a held action or answers a question. The scope names who may
|
|
2610
2682
|
* answer: its speaker when their tier holds the call, and the owners unless the conversation
|
|
2611
2683
|
* is private, where a call above the speaker's tier goes to no one and comes back `unavailable` at once.
|
|
2684
|
+
* Where the call names approvers and the conversation is shared, they answer it instead of the
|
|
2685
|
+
* speaker, at the tier it needs. A private conversation's person alone answers it, and only if
|
|
2686
|
+
* named or an owner; for any other the call comes back `unavailable` at once.
|
|
2612
2687
|
*/
|
|
2613
2688
|
prompts(channel: ChannelKey, scope?: PromptScope): Prompts {
|
|
2614
2689
|
return {
|
|
2615
|
-
confirm: async (
|
|
2690
|
+
confirm: async (
|
|
2691
|
+
title,
|
|
2692
|
+
_message,
|
|
2693
|
+
signal,
|
|
2694
|
+
minTier = "owner",
|
|
2695
|
+
_wait,
|
|
2696
|
+
_details,
|
|
2697
|
+
options,
|
|
2698
|
+
) => {
|
|
2699
|
+
const owners = scope?.escalate !== "none";
|
|
2700
|
+
const named = options?.approvers;
|
|
2701
|
+
// Alone with the bot, the person answers for themselves, if the call is theirs to approve:
|
|
2702
|
+
// they are named, or an owner.
|
|
2703
|
+
if (
|
|
2704
|
+
named &&
|
|
2705
|
+
!owners &&
|
|
2706
|
+
scope?.tier !== "owner" &&
|
|
2707
|
+
!named.includes(scope?.principalId ?? "")
|
|
2708
|
+
)
|
|
2709
|
+
return "unavailable";
|
|
2710
|
+
const shared = owners ? named : undefined;
|
|
2616
2711
|
const theirs =
|
|
2617
2712
|
scope !== undefined &&
|
|
2618
2713
|
TIERS.indexOf(scope.tier) >= TIERS.indexOf(minTier);
|
|
2619
|
-
const
|
|
2620
|
-
if (
|
|
2714
|
+
const principals = shared ?? (theirs ? [scope.principalId] : []);
|
|
2715
|
+
if (principals.length === 0 && !owners) return "unavailable";
|
|
2621
2716
|
return this.#ask(channel, title, signal, {
|
|
2622
|
-
|
|
2717
|
+
principals,
|
|
2623
2718
|
owners,
|
|
2719
|
+
...(shared ? { minTier } : {}),
|
|
2720
|
+
...(options?.approvedBy ? { approved: options.approvedBy } : {}),
|
|
2624
2721
|
});
|
|
2625
2722
|
},
|
|
2626
2723
|
ask: async () => undefined,
|
|
2627
2724
|
};
|
|
2628
2725
|
}
|
|
2629
2726
|
|
|
2630
|
-
/**
|
|
2727
|
+
/**
|
|
2728
|
+
* Someone answers an open prompt; it settles only when it is theirs to answer. `name` and
|
|
2729
|
+
* `speakerId` say who they are to a call that asks who approved it.
|
|
2730
|
+
*/
|
|
2631
2731
|
answer(
|
|
2632
2732
|
id: string,
|
|
2633
|
-
who: {
|
|
2733
|
+
who: {
|
|
2734
|
+
principalId: string;
|
|
2735
|
+
owner?: boolean;
|
|
2736
|
+
tier?: Tier;
|
|
2737
|
+
name?: string;
|
|
2738
|
+
speakerId?: string;
|
|
2739
|
+
},
|
|
2634
2740
|
approved: boolean,
|
|
2635
2741
|
): void {
|
|
2636
2742
|
const open = this.#answering.get(id);
|
|
2637
2743
|
if (!open) return;
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
open.
|
|
2744
|
+
// An answer that does not say its tier is not accepted where a tier is needed.
|
|
2745
|
+
const tierOk =
|
|
2746
|
+
!open.minTier ||
|
|
2747
|
+
(who.tier !== undefined &&
|
|
2748
|
+
TIERS.indexOf(who.tier) >= TIERS.indexOf(open.minTier));
|
|
2749
|
+
const theirs = open.principals.includes(who.principalId) && tierOk;
|
|
2750
|
+
if (!theirs && !(who.owner && open.owners)) return;
|
|
2751
|
+
if (approved)
|
|
2752
|
+
open.approved?.({
|
|
2753
|
+
principalId: who.principalId,
|
|
2754
|
+
speakerId: who.speakerId ?? who.principalId,
|
|
2755
|
+
name: who.name ?? who.principalId,
|
|
2756
|
+
...(who.owner ? { owner: true as const } : {}),
|
|
2757
|
+
});
|
|
2758
|
+
open.settle(approved ? "approved" : "declined");
|
|
2641
2759
|
}
|
|
2642
2760
|
|
|
2643
2761
|
#ask(
|
|
2644
2762
|
channel: ChannelKey,
|
|
2645
2763
|
title: string,
|
|
2646
2764
|
signal: AbortSignal | undefined,
|
|
2647
|
-
audience:
|
|
2765
|
+
audience: Omit<Answering, "settle">,
|
|
2648
2766
|
): Promise<Approval> {
|
|
2649
2767
|
const asked = {
|
|
2650
2768
|
id: `q${this.asked.length + 1}`,
|
|
@@ -3103,9 +3221,9 @@ It returns:
|
|
|
3103
3221
|
### `describeSurfaceContract`: what every chat surface keeps
|
|
3104
3222
|
|
|
3105
3223
|
A plugin that contributes a `ChatSurface` can test it against the contract every surface keeps.
|
|
3106
|
-
`describeSurfaceContract(name, make)` registers one test per check, each on a fresh `SurfaceContractSubject` from `make`: the `surface`, a `channel` of it, and one person on its network, who can `write(text)`, whose `observations()` list what they have seen (`SurfaceObservation`: a reply's text and file names, typing and stop controls going on and off, progress, and prompts opening and closing), and who may `answer(prompt, approved)` an approval; `speaker` is who they are to `surface.prompts`, given as `promptScope(speaker, visibility)`, an owner by default, and every approval the contract asks for needs exactly their tier. `stranger`, when given, is someone else on the network, another principal and not an owner, who knows the id of the person's open approval and may `join()` and `answer(prompt, approved)` it; `owner`, when given, is an owner on the network, another principal than the person, who does the same in the person's private conversation. Both are `SurfaceContractAnswerer`s.
|
|
3224
|
+
`describeSurfaceContract(name, make)` registers one test per check, each on a fresh `SurfaceContractSubject` from `make`: the `surface`, a `channel` of it, and one person on its network, who can `write(text)`, whose `observations()` list what they have seen (`SurfaceObservation`: a reply's text and file names, typing and stop controls going on and off, progress, and prompts opening and closing), and who may `answer(prompt, approved)` an approval; `speaker` is who they are to `surface.prompts`, given as `promptScope(speaker, visibility)`, an owner by default, and every approval the contract asks for needs exactly their tier. `stranger`, when given, is someone else on the network, another principal and not an owner, who knows the id of the person's open approval and may `join()` and `answer(prompt, approved)` it; `owner`, when given, is an owner on the network, another principal than the person, who does the same in the person's private conversation. Both are `SurfaceContractAnswerer`s. `approver`, when given, is a third person on the network with a `principalId`, not an owner and at the person's tier, whom the contract names in `approvers`; `privateOnly` marks a surface that serves only conversations private to one person, such as the web chat.
|
|
3107
3225
|
The contract starts the surface, calls the subject's `join()`, and stops the surface after the subject's `close()`.
|
|
3108
|
-
It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops, with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `unavailable` (or `expired`, as older surfaces answer) at once and is not shown.
|
|
3226
|
+
It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops, with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; with an `approver` and a `stranger`, that an approval naming the approver is answered by the approver and not by the person or the stranger, unless the surface is `privateOnly`, and, with an `approver`, that a private conversation ignores the names; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `unavailable` (or `expired`, as older surfaces answer) at once and is not shown.
|
|
3109
3227
|
A check for a part the surface does not offer passes.
|
|
3110
3228
|
`checkSurfaceContract(make)` runs the same checks and returns the ones that failed, as `SurfaceContractFailure`s.
|
|
3111
3229
|
|
|
@@ -3361,6 +3479,8 @@ Import from the entries listed below; source area files are internal.
|
|
|
3361
3479
|
| `Approval` | `pi-roundtable` | type |
|
|
3362
3480
|
| `ApprovalDetails` | `pi-roundtable` | type |
|
|
3363
3481
|
| `ApprovalFile` | `pi-roundtable` | type |
|
|
3482
|
+
| `ApprovalOptions` | `pi-roundtable` | type |
|
|
3483
|
+
| `ApprovedBy` | `pi-roundtable` | type |
|
|
3364
3484
|
| `AskOption` | `pi-roundtable` | type |
|
|
3365
3485
|
| `AttachmentFailure` | `pi-roundtable` | type |
|
|
3366
3486
|
| `AttachmentPort` | `pi-roundtable` | type |
|
|
@@ -3427,8 +3547,10 @@ Import from the entries listed below; source area files are internal.
|
|
|
3427
3547
|
| `HostStoppingError` | `pi-roundtable` | value |
|
|
3428
3548
|
| `HttpRoute` | `pi-roundtable` | type |
|
|
3429
3549
|
| `IDENTITY` | `pi-roundtable` | value |
|
|
3550
|
+
| `IDENTITY_LINKS` | `pi-roundtable` | value |
|
|
3430
3551
|
| `IdentityError` | `pi-roundtable` | value |
|
|
3431
3552
|
| `IdentityLink` | `pi-roundtable` | type |
|
|
3553
|
+
| `IdentityLinks` | `pi-roundtable` | type |
|
|
3432
3554
|
| `IdentityRef` | `pi-roundtable` | type |
|
|
3433
3555
|
| `IdentityService` | `pi-roundtable` | type |
|
|
3434
3556
|
| `ImageDrawer` | `pi-roundtable` | type |
|
|
@@ -3484,6 +3606,8 @@ Import from the entries listed below; source area files are internal.
|
|
|
3484
3606
|
| `Pronouns` | `pi-roundtable` | type |
|
|
3485
3607
|
| `ProviderError` | `pi-roundtable` | value |
|
|
3486
3608
|
| `Providers` | `pi-roundtable` | type |
|
|
3609
|
+
| `Provisioning` | `pi-roundtable` | type |
|
|
3610
|
+
| `ProvisioningRule` | `pi-roundtable` | type |
|
|
3487
3611
|
| `QueuePort` | `pi-roundtable` | type |
|
|
3488
3612
|
| `Recurrence` | `pi-roundtable` | type |
|
|
3489
3613
|
| `ReferenceImage` | `pi-roundtable` | type |
|
|
@@ -3521,6 +3645,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
3521
3645
|
| `ScheduleError` | `pi-roundtable` | value |
|
|
3522
3646
|
| `ScheduleStore` | `pi-roundtable` | type |
|
|
3523
3647
|
| `ScheduledOutcome` | `pi-roundtable` | type |
|
|
3648
|
+
| `SelfLinkRules` | `pi-roundtable` | type |
|
|
3524
3649
|
| `Service` | `pi-roundtable` | type |
|
|
3525
3650
|
| `ServiceKey` | `pi-roundtable` | type |
|
|
3526
3651
|
| `ServiceStartOutcome` | `pi-roundtable` | type |
|
|
@@ -164,6 +164,11 @@ function contractSubject(tier: "owner" | "member") {
|
|
|
164
164
|
answer: async (id: string, approved: boolean) =>
|
|
165
165
|
surface.answer(id, { principalId: "p0", owner: true }, approved),
|
|
166
166
|
},
|
|
167
|
+
approver: {
|
|
168
|
+
principalId: "p3",
|
|
169
|
+
answer: async (id: string, approved: boolean) =>
|
|
170
|
+
surface.answer(id, { principalId: "p3", tier }, approved),
|
|
171
|
+
},
|
|
167
172
|
};
|
|
168
173
|
}
|
|
169
174
|
|
|
@@ -172,6 +177,138 @@ describeSurfaceContract("the fake chat for a member", async () =>
|
|
|
172
177
|
contractSubject("member"),
|
|
173
178
|
);
|
|
174
179
|
|
|
180
|
+
test("an approval that names approvers is answered by them at the tier it needs, and reports who approved", async () => {
|
|
181
|
+
const surface = new FakeSurface();
|
|
182
|
+
const speaker = {
|
|
183
|
+
id: "1",
|
|
184
|
+
name: "Ada",
|
|
185
|
+
tier: "member",
|
|
186
|
+
principalId: "p1",
|
|
187
|
+
} as const;
|
|
188
|
+
const prompts = surface.prompts("fake:1", promptScope(speaker));
|
|
189
|
+
let approver: unknown;
|
|
190
|
+
const answer = prompts.confirm(
|
|
191
|
+
"Send?",
|
|
192
|
+
"sends a message",
|
|
193
|
+
undefined,
|
|
194
|
+
"admin",
|
|
195
|
+
undefined,
|
|
196
|
+
undefined,
|
|
197
|
+
{
|
|
198
|
+
approvers: ["p3"],
|
|
199
|
+
approvedBy: (by) => {
|
|
200
|
+
approver = by;
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
// Not the speaker, not another principal, and not a named approver below the tier.
|
|
205
|
+
surface.answer("q1", { principalId: "p1" }, true);
|
|
206
|
+
surface.answer("q1", { principalId: "p2" }, true);
|
|
207
|
+
surface.answer("q1", { principalId: "p3", tier: "member" }, true);
|
|
208
|
+
expect(surface.asked[0]?.open).toBe(true);
|
|
209
|
+
surface.answer("q1", { principalId: "p3", tier: "admin", name: "Bo" }, true);
|
|
210
|
+
expect(await answer).toBe("approved");
|
|
211
|
+
expect(approver).toEqual({ principalId: "p3", speakerId: "p3", name: "Bo" });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a private conversation's approval that names others is not shown to a person who is not named", async () => {
|
|
215
|
+
const surface = new FakeSurface();
|
|
216
|
+
const speaker = {
|
|
217
|
+
id: "1",
|
|
218
|
+
name: "Ada",
|
|
219
|
+
tier: "member",
|
|
220
|
+
principalId: "p1",
|
|
221
|
+
} as const;
|
|
222
|
+
const prompts = surface.prompts("fake:1", promptScope(speaker, "private"));
|
|
223
|
+
expect(
|
|
224
|
+
await prompts.confirm(
|
|
225
|
+
"Send?",
|
|
226
|
+
"sends a message",
|
|
227
|
+
undefined,
|
|
228
|
+
"member",
|
|
229
|
+
undefined,
|
|
230
|
+
undefined,
|
|
231
|
+
{ approvers: ["p3"] },
|
|
232
|
+
),
|
|
233
|
+
).toBe("unavailable");
|
|
234
|
+
expect(surface.asked).toEqual([]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("a private conversation's approval that names others is shown to its person when they are an owner (RV2-F004)", async () => {
|
|
238
|
+
const surface = new FakeSurface();
|
|
239
|
+
const owner = {
|
|
240
|
+
id: "1",
|
|
241
|
+
name: "Riley",
|
|
242
|
+
tier: "owner",
|
|
243
|
+
principalId: "p1",
|
|
244
|
+
} as const;
|
|
245
|
+
const prompts = surface.prompts("fake:1", promptScope(owner, "private"));
|
|
246
|
+
let approver: unknown;
|
|
247
|
+
const answer = prompts.confirm(
|
|
248
|
+
"Send?",
|
|
249
|
+
"sends a message",
|
|
250
|
+
undefined,
|
|
251
|
+
"member",
|
|
252
|
+
undefined,
|
|
253
|
+
undefined,
|
|
254
|
+
{ approvers: ["p3"], approvedBy: (by) => (approver = by) },
|
|
255
|
+
);
|
|
256
|
+
expect(surface.asked[0]?.open).toBe(true);
|
|
257
|
+
surface.answer("q1", { principalId: "p3", tier: "member" }, true);
|
|
258
|
+
expect(surface.asked[0]?.open).toBe(true);
|
|
259
|
+
surface.answer("q1", { principalId: "p1", owner: true, tier: "owner" }, true);
|
|
260
|
+
expect(await answer).toBe("approved");
|
|
261
|
+
expect(approver).toMatchObject({ principalId: "p1", owner: true });
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("a private conversation's approval that names its person is answered by them alone", async () => {
|
|
265
|
+
const surface = new FakeSurface();
|
|
266
|
+
const speaker = {
|
|
267
|
+
id: "1",
|
|
268
|
+
name: "Ada",
|
|
269
|
+
tier: "member",
|
|
270
|
+
principalId: "p1",
|
|
271
|
+
} as const;
|
|
272
|
+
const prompts = surface.prompts("fake:1", promptScope(speaker, "private"));
|
|
273
|
+
const answer = prompts.confirm(
|
|
274
|
+
"Send?",
|
|
275
|
+
"sends a message",
|
|
276
|
+
undefined,
|
|
277
|
+
"member",
|
|
278
|
+
undefined,
|
|
279
|
+
undefined,
|
|
280
|
+
{ approvers: ["p1", "p3"] },
|
|
281
|
+
);
|
|
282
|
+
surface.answer("q1", { principalId: "p3", tier: "member" }, true);
|
|
283
|
+
expect(surface.asked[0]?.open).toBe(true);
|
|
284
|
+
surface.answer("q1", { principalId: "p1" }, false);
|
|
285
|
+
expect(await answer).toBe("declined");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("an answer that does not say its tier is not accepted where the approvers need one", async () => {
|
|
289
|
+
const surface = new FakeSurface();
|
|
290
|
+
const speaker = {
|
|
291
|
+
id: "1",
|
|
292
|
+
name: "Ada",
|
|
293
|
+
tier: "member",
|
|
294
|
+
principalId: "p1",
|
|
295
|
+
} as const;
|
|
296
|
+
const prompts = surface.prompts("fake:1", promptScope(speaker));
|
|
297
|
+
const answer = prompts.confirm(
|
|
298
|
+
"Send?",
|
|
299
|
+
"sends a message",
|
|
300
|
+
undefined,
|
|
301
|
+
"admin",
|
|
302
|
+
undefined,
|
|
303
|
+
undefined,
|
|
304
|
+
{ approvers: ["p3"] },
|
|
305
|
+
);
|
|
306
|
+
surface.answer("q1", { principalId: "p3" }, true);
|
|
307
|
+
expect(surface.asked[0]?.open).toBe(true);
|
|
308
|
+
surface.answer("q1", { principalId: "p3", tier: "admin" }, true);
|
|
309
|
+
expect(await answer).toBe("approved");
|
|
310
|
+
});
|
|
311
|
+
|
|
175
312
|
test("two surfaces with one prefix are refused, naming both plugins", async () => {
|
|
176
313
|
const second = definePlugin({
|
|
177
314
|
name: "second-chat",
|