Replace the single-scalar cost model on AgentRun with an append-only
UsageFact ledger. One AgentRun owns zero or more UsageFact rows; each
records one billable consumption event (model completion, external
capability, or tool proxy) with its own provider/model/tokens/quantity/
cost. AgentRun.costUsd/inputTokens/outputTokens become a derived rollup
cache.
This unblocks external capabilities (PDF->MD bundle, audio/video->text)
that bill in non-token units (pages, seconds) through a different
provider than the main agent loop, without per-capability schema changes
or nested AgentRuns (which would pollute lock/admission/session
semantics).
Contract:
- spec/Spec/System/Agent/Usage.lean pins UsageFact, UsageFactKind,
CostSource and three invariants: append-only; belongs to one run,
never holds a lock; missing cost != zero (ADR-0022).
- ADR-0026 records the decision, the rejected nested-Run alternative,
the rollup cache strategy, and the deferred capability registry /
pricebook / post-hoc correction flows.
Schema:
- UsageFact model with indexes on (runId, occurredAt), (runId, kind),
(provider, model, occurredAt), (capabilityId, occurredAt).
- Migration backfills one synthetic model_completion fact per existing
run with recorded cost/tokens (correlationId = runId marks backfill);
truly unrecorded runs stay runsWithoutCost per ADR-0022.
Write path (trigger finish):
- Write the UsageFact first, then mirror it onto AgentRun as two
separate statements (not one transaction). The fact is the truth so it
goes first; the cache is derived so it goes second. A crash between
them leaves the cache stale but the usage service re-reads facts
directly, so this is recoverable; the reverse order would lose the
truth. Separate statements also avoid an AgentRun row lock held across
the insert's FK ShareLock, which deadlocked concurrent workspace
teardown under the Organization->Project->AgentRun->UsageFact cascade.
Read paths:
- org/usage.ts aggregates from UsageFact, ignoring the AgentRun cache.
- slash /usage buckets by (fact.provider, fact.model); a run with a
main loop + an external call lands in two buckets.
- session detail exposes usageFacts[] + costSource for future per-run
cost-breakdown UI.
Tests:
- usage.test.ts: 6 integration tests pin fact aggregation, missing-cost-
!=-zero, multi-fact-per-run, empty-run, project-level, empty-org.
- trigger.test.ts: existing /usage assertion ($0.0023,
openrouter / mock-model) passes on the fact path.
- feishu-reactions mock prisma gains usageFact.create.
Drop markdown_to_pdf MCP surface, implementation, tests, and md-to-pdf dependency.
Roles that still list markdown_to_pdf must be cleaned before startup.
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
Teachers need ad-hoc Markdown → PDF. Ship an MCP tool powered by
md-to-pdf (Marked + headless Chrome) so remote images/CSS work, with
workspace-scoped basedir, front-matter stripped so untrusted markdown
cannot override dest/basedir/launch options, and MathJax for $/$ math.
Also include WebFetch and WebSearch in the unrestricted role tool
surface by default. Deploy skips Puppeteer's browser download and
expects a host Chrome/Chromium (PUPPETEER_EXECUTABLE_PATH / CHROME_PATH).
Add a ⛔ 中断 button to live streaming cards; Feishu's native confirm
dialog is the confirmation step, so no extra card round-trip is needed.
- builder.ts: render interrupt action (danger button + confirm) while the
run is live; new `interrupted` flag renders a 已中断 footer instead of
完成/失败 on the complete card.
- streaming-card.ts: finish(text, { interrupted }) threads the flag into
the final patch; overflow cards suppress the button.
- runner.ts: RunRequest gains abortController, passed to the SDK query;
abort surfaces as RunStatus "interrupted" (no error) in the catch.
- trigger.ts: activeRuns registry maps runId → AbortController; onCardAction
resolves the interrupt button, authorizes agent.cancel, aborts the run.
Interrupted runs persist as CANCELED (spec RunState.canceled, terminal →
lock released per ADR-0002).
- authorizer.ts: agent.cancel now consults PermissionSettings.agentCancel
(MANAGE_ONLY/DISABLED) — previously only agentTrigger was honored, but
spec/PermissionGrant deliberately splits these knobs ("谁能触发" ≠
"谁能取消"). Default role remains MANAGE (Capability.normalCancel).
Tests: runner abort unit test, trigger interrupt + denied integration
tests, three agent.cancel authorization tests (manage allowed, edit denied,
DISABLED policy denies even manage). 26 files / 175 tests pass.
Replace text-only PatchableTextStream with a unified StreamingAgentCard that
renders the full agent run lifecycle in a single Feishu interactive card:
- Tool-use panel: collapsible, shows each tool call with status (running/
success/error), input summary, and result/error in code blocks
- Reasoning panel: collapsible, streams thinking text inline then collapses
on completion
- Answer text: rendered via native Feishu markdown component (no post-row
round-trip)
Runner now captures previously-discarded SDK events:
- thinking_delta → reasoning stream
- tool_use id + input on content_block_start / assistant message
- tool_result from user messages (was entirely unhandled)
New files:
- card/trace-store.ts: per-run in-memory tool-use trace with secret redaction
- card/builder.ts: Feishu card JSON builder (tool panel + reasoning + text)
- card/streaming-card.ts: StreamingAgentCard controller (400ms throttled)
client.ts: add sendCard/patchCard raw JSON primitives
Two bugs were introduced when sendTextMessage was changed to auto-detect
message format (text/post/interactive):
1. Message type mismatch: sendTextMessage could create a text or post
message, but patchTextMessage only works on interactive cards.
Fix: add sendInteractiveCardMessage for streaming create (always
interactive, always patchable).
2. flushTextToSink state corruption: the text management after flush
had a flawed appendedDuringFlush heuristic that could lose text or
duplicate it. Fix: properly track sent vs unsent text by removing
the sent prefix length, keeping only the last chunk for future patches.
Also fix finish() to flush pending text regardless of currentMessageId.
- parsePostMessage: full Feishu post payload parser with locale fallback,
supports text/a/at/img/media/file/code_block/br/hr/emotion elements
- Trigger accepts post messages, extracts text + downloads attachments
- Post messages with attachments bypass batching, text-only posts batch
- Add unit tests (10) for all element types and edge cases
- Increase max message length from 4000 to 8000 chars
- Add splitAtBoundary: paragraph > newline > space priority, never split
inside fenced code blocks (move split to before opening fence)
- Add sendLongText: sends multi-chunk long messages sequentially
- Update PatchableTextStream.flush to use splitAtBoundary
- Add unit tests (6) for splitting edge cases
- sendApprovalCard: interactive card with configurable buttons
- resolveCard: patch card to show resolution state (green/red)
- ApprovalManager: register/resolve lifecycle with 5min timeout
- request_approval MCP tool: agent can ask user for confirmation
- Wire onCardAction in trigger handler and server startup
- Add unit tests (5) for card JSON, manager, and routing
- Create MessageBatcher: debounce 600ms, adaptive delay for long chunks,
max 8 messages/4000 chars before immediate flush, per (chatId, sender) key
- Integrate into trigger: text messages enqueued, slash commands and
file/image bypass batching, locked projects respond immediately
- Add unit tests (8) for batcher lifecycle and edge cases
- Add buildOutboundPayload: detect markdown format, isolate code blocks,
force plain text for tables, fallback from post to text on API rejection
- Add addReaction/removeReaction for processing status lifecycle
- Replace THUMBSUP with Typing→(remove on success | CrossMark on failure)
- Add unit tests for markdown rendering (15) and reactions (6)