-
Notifications
You must be signed in to change notification settings - Fork 0
Chat Runtime, Conversation & Cost
This page covers the provider-agnostic core of chat: src/core/chat/runtime.ts, src/core/chat/conversation.ts, and src/core/chat/cost.ts. None of these files import Electron, touch the filesystem, or know which provider is in use. The same createChatRuntime() is instantiated by the Electron main process and by the Server Edition, each supplying its own ChatRuntimeDeps (provider resolution, event broadcast, tool set, driver factory). Everything provider-specific lives behind the injected ChatDriver, implemented by openai.ts / anthropic.ts and documented on the Model Provider Adapters & Streaming page; the loop that consumes those drivers lives in tools.ts (see Chat Tool Calling & Project Tools).
createChatRuntime(deps) returns a ChatRuntime with four methods: send, stop, approve, isBusy. The state behind them is deliberately small:
-
activeRunsis a module-levelMap<string, AbortController>keyed bynodeId. It is not per-runtime-instance, so two runtimes created in the same JS realm (it happens in tests) share the busy map. -
pendingApprovalsis a closure-localMap<string, resolver>keyed by`${nodeId}:${callId}`.
send(req) is the only entry point that starts work. Its order of operations matters:
- If
activeRuns.has(req.nodeId), return{ ok: false, error: 'chat already running for this node' }. There is no queueing and no coalescing — concurrency is rejected. -
deps.resolveProvider(req)must return aChatProviderConfig.nullreturns{ ok: false, error: 'no chat provider configured …' }. The runtime does not read settings or env itself; the contract (per the file header) is that env wins over settings, the same rule the Telegram token uses. - Register an
AbortControllerinactiveRunsbefore any I/O, sostop()andisBusy()are correct from the first tick. - Build the driver (
deps.driverFor?.(cfg) ?? driverFor(cfg)) and the tool list (deps.toolsFor?.(req) ?? []— no tools by default). - Resolve the model with
req.model ?? cfg.model ?? ''and hand[...req.messages]torunChatLoop. Note the shallow copy: the loop may append tool/assistant messages without mutating the caller's array, but individualChatMessageobjects are shared by reference.
Note that the two early-exit paths return before the try, so they broadcast no done event. A caller that already rendered an "in progress" state must surface those errors itself.
sequenceDiagram
participant UI as Chat node UI
participant RT as ChatRuntime.send
participant Loop as runChatLoop (tools.ts)
participant Drv as ChatDriver (openai/anthropic)
UI->>RT: send(req)
RT->>RT: busy check, resolveProvider, activeRuns.set(nodeId, controller)
RT->>Loop: runChatLoop(driver, model, messages, tools, hooks)
Loop->>Drv: stream(opts)
Drv-->>Loop: streamed chunks / usage
Loop-->>RT: onEvent(e) — every kind except 'done'
RT-->>UI: deps.broadcast(nodeId, e)
Loop->>RT: requestApproval(call)
RT->>RT: pendingApprovals.set(nodeId:callId, resolve)
RT-->>UI: broadcast toolCall (permission card)
UI->>RT: approve(nodeId, callId, decision)
RT->>Loop: resolver(decision) — entry deleted from the map
Loop->>Drv: stream again with the tool result re-entered
Loop-->>RT: { stopReason }
RT-->>UI: broadcast done(reason)
RT->>RT: finally: activeRuns.delete(nodeId)
Key nodes:
-
onEventfilter. The runtime forwards every event kind exceptdone, because it emits its owndoneafter the loop settles, with a reason it computes itself. TheChatEventunion itself is declared in./typesalongside the message/tool contracts. -
Approval is a promise, not a callback.
requestApprovalreturns aPromise<'approve' | 'deny'>whose resolver is parked inpendingApprovals; the loop blocks untilapprove()is called. The runtime broadcasts thetoolCallevent itself, and the comment at the call site warns that the loop also re-broadcasts the call — the renderer is expected to dedupe by call id.approve()is a no-op for an unknown key, so duplicate or late clicks are harmless. - Tool-result re-entry is entirely the loop's business: the runtime never inspects tool calls or results, it only shuttles the approval decision back.
stop(nodeId) only calls abort() on the controller; the activeRuns entry survives until send's finally. A user stop is treated as a normal outcome, not an error:
| Situation |
done reason broadcast |
ChatSendResult |
|---|---|---|
| Signal aborted after the loop returned | stopped |
{ ok: true, stopReason } |
Loop returned stopReason === 'max_iterations'
|
max_iterations |
{ ok: true, stopReason } |
| Loop returned normally | end_turn |
{ ok: true, stopReason } |
Driver threw AbortError
|
stopped |
{ ok: true, stopReason: 'stopped' } |
| Any other throw | error |
{ ok: false, error: message } |
The finally block deletes the run entry and logs chat <nodeId> finished in <ms> through deps.log. Two ordering details are worth knowing before you build on this:
- The
donebroadcast happens inside thetry, beforefinallyremoves the entry. A UI that reacts todoneby immediately callingsendcan observeisBusy(nodeId) === truefor a brief window and get the "chat already running" error. -
pendingApprovalsentries are only removed byapprove(). If a run ends (abort, error, or loop return) while an approval card is still outstanding, its resolver stays in the map; a laterapprove()for the same node/call id resolves an already-settled promise and is silently dropped. There is no sweep on run teardown.
stateDiagram-v2
[*] --> Idle
Idle --> Running: send() stores AbortController in activeRuns
Running --> AwaitingApproval: requestApproval(call) parks resolver
AwaitingApproval --> Running: approve()/deny() deletes resolver and resolves
Running --> Terminal: loop returns
AwaitingApproval --> Terminal: loop settles after abort
Terminal --> Idle: finally deletes activeRuns entry
note right of AwaitingApproval
Resolvers are only removed by approve();
send()'s finally does not purge them.
end note
note right of Terminal
done is broadcast before the finally runs,
so isBusy can still be true right after done.
end note
conversation.ts owns one chat session's message list: Conversation { id, messages, createdAt }, created by createConversation(). It has zero dependencies and no I/O, which is what makes it usable from both the runtime side and the renderer-side owner of the message list.
Mutation API. appendMessage(role, content) pushes a fully formed message. The streaming appliers — appendDelta, appendThinking, setUsage, markStopped — all route through ensureMessage, which creates a placeholder { role: 'assistant', content: '', ts } when the id is unknown. That means event ordering is not a correctness requirement: a delta can arrive before the message it belongs to exists, and it will be materialized. setUsage stamps msg.usage and, when supplied, msg.model — the two fields cost.ts later reads.
The note role. appendNote writes local-only annotations (the comment names /cost output as the example). Notes persist with the transcript but are never mapped onto the wire — the comment records the reason: a system-role note silently vanished from Anthropic's context (audit B4). isValidRole accepts user | assistant | system | tool | note, and deserializeConversation uses the same predicate, so any new role must be added in both places.
Slash commands. detectSlashCommand is a pure parser for /clear, /model <id>, /system <text>, /cost. The command word is case-insensitive; a missing leading slash or an unknown word returns null. It only detects — execution (clearing the list, switching model, inserting a system prompt) belongs to the caller.
Persistence and the history window. serializeConversation emits { v: 1, messages }; capConversationMessages is exported separately so node-data persistence (project.json) can apply the same window without going through the serializer (audit B5). The cap drops oldest user+assistant pairs and always keeps a leading system message.
flowchart TD
A[messages] --> B{JSON.stringify v:1 messages fits maxBytes?}
B -- yes --> C[return messages unchanged]
B -- no --> D[start = 1 if messages[0] is system, else 0]
D --> E{start + 1 < capped.length?}
E -- no --> F[return capped, still over budget]
E -- yes --> G{capped[start] is user and capped[start+1] is assistant?}
G -- yes --> H[drop the pair]
G -- no --> I[start += 1]
H --> J{fits maxBytes?}
I --> J
J -- yes --> K[return capped]
J -- no --> E
Consequences you should know before changing the cap:
- The budget is compared against
String.lengthof the serialized JSON, i.e. UTF-16 code units, despite themaxBytesname and the 200 KiB default. Non-ASCII transcripts consume more real bytes than the check accounts for. - If nothing is droppable (a lone system message, or a leading run that never lines up as a user+assistant pair at
start), the loop advancesstartto the end and returns an over-budget array rather than looping forever. -
serializeConversationwrites only{ v, messages }— it does not persistconv.idorconv.createdAt. A serialize→deserialize round-trip therefore synthesizes fresh values when they are absent.
Deserialization. deserializeConversation throws TypeError for invalid JSON, non-object payloads, a non-array messages, and messages missing id/valid role/content/ts. Everything else is copied verbatim so unknown fields survive the round-trip. That tolerance is one-directional: nested values such as usage, thinking, or tool-call payloads are not deep-validated, and cost.ts assumes usage.inputTokens/outputTokens are numbers.
cost.ts is pure and has no knowledge of providers. DEFAULT_PRICES maps model prefixes to USD per million tokens (gpt-, gpt-4o, claude-, claude-3-5-haiku, deepseek). priceFor(model, overrides) tries an exact override hit first, then scans overrides and defaults together and keeps the longest matching prefix. Two implications:
- Overrides participate in prefix matching, so a short override prefix (say
claude-) loses to the longer built-inclaude-3-5-haikuentry; a specific model override needs a prefix at least as long as the longest competing default. - No match returns
null.costOfturns that into{ usd: 0, estimated: true }so the UI can render "n/a" instead of a fabricated number.
conversationCost(messages, model, overrides) sums assistant messages that carry usage, using m.model ?? model per message. Be precise about the flag: estimated is true only when no message had a known price. A conversation mixing priced and unpriced models reports estimated: false with a total that undercounts the unknown ones.
Model price overrides are an input parameter here; the settings key that supplies them is outside these three files.
-
runtime.tsis the orchestrator and the only stateful piece: run registry, abort, approval plumbing, provider/driver/tool resolution, terminal event emission. It knows aboutChatEvent,ChatMessage, and theChatDriver/ChatToolDefinterfaces but not about HTTP, OpenAI shapes, or tool schemas. -
conversation.tsis the session model and the persistence format. It is where streamed bytes become messages, where the byte-capped window is enforced, and where slash commands are recognized. It is the authority on what a message may contain. -
cost.tsis a pure function library over{ usage, model }pairs produced by the conversation model and populated from provider usage reports.
They connect in one direction: a caller passes messages into ChatRuntime.send; the runtime broadcasts stream events; the message-list owner applies them with the conversation.ts appliers, which stamp usage/model; cost.ts reads those stamped fields (directly, or via /cost, which lands as a note message). Provider adapters and the tool loop are the two injected seams that keep the runtime provider-agnostic.
-
New provider shape. Extend
driverFor'sapiswitch, or injectdeps.driverForto bypass the built-in adapters entirely. The new adapter must honorsignaland surface aborts as either adoneevent or anAbortError— the runtime explicitly handles both. -
New tool set. Supply
deps.toolsFor(req); returning[]disables tool calling for a send. Approval prompts only appear for tools the loop decides to gate throughrequestApproval. -
New slash command. Add to
SlashCommandName, theswitchindetectSlashCommand, and the caller that executes commands; nothing in the runtime needs to change. -
Different history budget. Pass
maxBytestoserializeConversation/capConversationMessages. Remember persistence paths that call the helper directly. -
New pricing. Add a prefix to
DEFAULT_PRICESor pass an override table long enough to win the longest-prefix comparison. -
Testing.
deps.driverFor,deps.toolsFor,deps.resolveProvider, anddeps.broadcastare all injectable, so the whole loop can be driven by a fake driver with no network and no filesystem.
tools.ts, types.ts, openai.ts, and anthropic.ts are visible here only through the interfaces runtime.ts imports (runChatLoop, ChatDriver, ChatToolDef, ChatError, ChatEvent, ChatMessage, streamOpenAI, streamAnthropic). The exact ChatEvent union, the iteration/max_iterations policy, the tool schema shape, and SSE/thinking parsing are covered on the Chat Tool Calling & Project Tools and Model Provider Adapters & Streaming pages. The renderer-side owner that calls the conversation appliers and reacts to the approval-card dedupe rule is covered by Chat Node UI. The source of price overrides in settings is not determinable from these files.
Sources: src/core/chat/runtime.ts, src/core/chat/conversation.ts, src/core/chat/cost.ts
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance