Skip to content

Add multi-agent chat example with browser acceptance tests#4

Open
taras wants to merge 135 commits intocowboyd:mainfrom
taras:examples/multi-agent-chat
Open

Add multi-agent chat example with browser acceptance tests#4
taras wants to merge 135 commits intocowboyd:mainfrom
taras:examples/multi-agent-chat

Conversation

@taras
Copy link
Copy Markdown

@taras taras commented Mar 27, 2026

Summary

  • Adds a full multi-agent chat demo (examples/multi-agent-chat) using Tisyn's compiled workflow, durable journaling, and WebSocket browser transport
  • Browser acceptance tests using Playwright + in-page Tisyn executor with @testing-library/dom Dom agent
  • Coarse-grained Browser().execute({ workflow: <ir-payload> }) pattern — DOM interactions run inside the page via a local Dom agent, crossing the host/browser boundary once per scenario fragment
  • Durable journaling with host restart / reconnect support
  • Vite dev server WS proxy for local demo workflow

Package changes

  • @tisyn/agent: export Workflow<T> public type
  • @tisyn/ir: export IrInput and TypedIrNode; fix Call() signature
  • @tisyn/compiler: fix codegen for composite declarations, while loop Case A bindings, IR print() output
  • @tisyn/runtime: align execute() with single-payload Eval() canonical form
  • @tisyn/transport: add ./protocol-server sub-path export

Test plan

  • pnpm run test — 21 unit tests pass
  • pnpm run test:browser — 4 browser acceptance tests pass (basic send/receive, transcript restore, host restart, second browser read-only)

taras added 30 commits March 26, 2026 20:57
Add the five core specification documents:

- tisyn-specification-1.0.md: System specification (v1.0.0) covering
  IR grammar, evaluation model, environment, structural/external
  operations, concurrency, resolve, validation, runtime, wire
  protocol, determinism, error semantics, and agent abstraction.

- tisyn-architecture.md: Architecture and design rationale covering
  the pipeline, execution flow, IR design, kernel model, effects/
  journaling, deterministic replay, concurrency, agent model, error/
  cancellation flow, transport, compiler, and comparison to existing
  systems.

- tisyn-kernel-specification.md: Kernel specification (v1.0.0)
  detailing evaluation rules, environment model, resolve function,
  eval node execution, structural operations, quote semantics,
  concurrency model, error handling, journaling, replay semantics,
  determinism guarantees, and validation.

- tisyn-compiler-specification-1.1.0.md: Compiler specification
  (v1.1.0) covering the compilation pipeline, yield* desugaring,
  sequential statement compilation, control flow transformations,
  expression compilation, concurrency compilation, function
  semantics, and unsupported constructs.

- tisyn-agent-specification-1.1.0.md: Agent implementation
  specification (v1.1.0) covering agent role, operation registration,
  execution contract, idempotency, cancellation, concurrency, error
  handling, serialization, reconnect/duplicates, and progress
  reporting.
Set up the monorepo structure:
- pnpm workspace with 6 packages (shared, kernel, durable-streams,
  runtime, agent, conformance)
- Root tsconfig.json with project references following the effectionx
  pattern (composite, NodeNext, ES2022)
- oxlint for linting, oxfmt for formatting
- vitest for testing in conformance package
- Effection ^4 as peer dependency
- Removed Deno configuration (deno.json, mod.ts)
…ncoding

Implement @tisyn/shared with the core protocol types:
- ir.ts: Five IR node types (Eval, Quote, Ref, Fn, Literal) with
  type guards and classifyNode() for validation
- values.ts: Json and Val types per System Spec §2.2
- events.ts: YieldEvent, CloseEvent, EffectDescriptor, EventResult
  per Conformance Suite §4.5
- errors.ts: All 9 error types from Conformance Suite §4.1
  (MalformedIR through DivergenceError)
- canonical.ts: Canonical JSON encoding per Conformance Suite §4.3
  (lexicographic keys, shortest numbers, no whitespace)
- effect-id.ts: parseEffectId() per Kernel Spec §4.6
Implement @tisyn/kernel with the full evaluation model:
- environment.ts: Linked-list immutable frames with lookup, extend
- classify.ts: STRUCTURAL/EXTERNAL classification of Eval IDs
- eval.ts: Generator-based evaluator implementing all 5 rules
  (Literal, Ref, Quote, Fn, Eval) and all 18 structural operations
  (let, seq, if, while, call, get, arithmetic, comparison,
  equality, short-circuit, unary, construct, array, concat, throw)
- resolve.ts: Recursive resolution with opaque value rule —
  values from lookup/eval are terminal, never recursed into
- unquote.ts: Quote stripping for structural operation data
- validate.ts: Two-phase validation (structural grammar + semantic
  single-Quote rule with positions table)

The kernel is a pure generator that yields EffectDescriptors.
It knows nothing about agents, journals, or transport.
…ation

Implement @tisyn/durable-streams:
- stream.ts: DurableStream interface (readAll, append) and
  InMemoryStream with clone-on-write safety, appendCount tracking,
  and injectFailure for persist-before-resume testing
- replay-index.ts: Per-coroutine cursored access to stored Yield
  events with peekYield/consumeYield and Close event lookup

Mirrors the @effectionx/durable-streams API pattern. Operations
are Effection generators for compatibility with the runtime.
Implement @tisyn/agent:
- AgentRegistry with register() and dispatch() — parses dotted
  effect IDs and routes to registered agent handlers

Implement @tisyn/runtime:
- execute() Operation that drives the kernel generator with:
  - IR validation before evaluation (MalformedIR → no journal)
  - ReplayIndex-first dispatch (match by type+name only)
  - Divergence detection (D1: description mismatch, D2: continue
    past close) per Conformance Suite §10.4
  - Live dispatch to agents via AgentRegistry
  - Persist-before-resume invariant (Yield written before kernel
    resumes)
  - Close(ok) on completion, Close(err) on error
  - Effect errors re-raised into kernel via generator.throw()

Full monorepo builds cleanly with tsc --build.
Implement @tisyn/conformance:
- harness.ts: Test runner supporting evaluation, effect, replay,
  negative_validation, and negative_runtime fixture types with
  canonical comparison, '<any>' sentinel handling, and mock agents
- fixtures.ts: All 11 Core conformance fixtures from the spec:
  KERN-001 (literal), KERN-020 (let), KERN-034 (call-site resolution),
  KERN-080 (sequential effects), KERN-071 (opaque value rule),
  REPLAY-010 (partial replay), REPLAY-020 (divergence detection),
  NEG-020 (malformed IR), NEG-001 (unbound variable),
  DET-005 (float determinism), KERN-014 (short-circuit)
- crash-replay.test.ts: End-to-end crash/replay scenario verifying
  stored effects replay without re-executing and live dispatch resumes

Fix replay journal tracking: stored events are only included in
the returned journal when successfully consumed (not on divergence).

All 12 tests pass.
- Remove unused imports (isEvalNode, isRefNode, isFnNode from
  validate.ts; ExecuteOptions from harness.ts; Val from crash test)
- Remove unused variable (yieldIndex, obj)
- Prefix unused parameters with underscore
- Add .oxlintrc.json disabling require-yield (intentional synchronous
  generators for Effection Operation interface)
- Apply oxfmt formatting across all source files

Build: clean. Lint: 0 warnings, 0 errors. Tests: 12/12 pass.
…er, errors

Create packages/compiler with foundational modules:
- ir-builders.ts: Pure constructors for all Tisyn IR nodes —
  structural ops (Let, If, While, Call, Get, etc.), arithmetic,
  comparison, equality, logical, unary, Construct, Array, Concat,
  Throw, ExternalEval, AllEval, RaceEval. Each wraps data in
  Quote for structural ops, leaves data unquoted for external ops.
- agent-id.ts: PascalCase → kebab-case conversion per Compiler
  Spec §4.2 (OrderService → 'order-service')
- counter.ts: Monotonic counter for deterministic synthetic names
  (__discard_0, __loop_0, __sub_0, __all_0)
- errors.ts: CompileError with source location and all error codes
  E001–E030 from Compiler Spec §11
Implement @tisyn/compiler per Compiler Specification 1.1.0:

Pipeline (4 phases):
- Parse: ts.createSourceFile → extract generator function declarations
- Discover: Classify yield* sites, detect unsupported constructs
- Transform: Early return rewrite, while-with-return → recursive Fn+Call
- Emit: AST → Tisyn IR with kernel validation (default on)

Expression compilation (§7):
- Literals, identifiers (→ Ref), property access (→ Get)
- All arithmetic/comparison/equality/logical operators
- Object literals (→ Construct), arrays (→ Array)
- Template literals (→ Concat), arrow functions (→ Fn)
- Ternary expressions (→ If), throw (→ Throw)

Statement compilation (§5):
- Block-to-Let transformation for sequential const declarations
- Early return: code after if-with-return moves into else branch
- While (Case A, no return) → While IR node
- While (Case B, with return) → recursive Fn + Call via call-site
  resolution, matching Compiler Spec §6.2 exactly
- Discarded effects → Let with synthetic __discard_N names

Effect compilation (§4):
- Agent effects: yield* X().method(args) → ExternalEval with
  kebab-case agent ID (OrderService → 'order-service')
- Concurrency: yield* all/race([...]) → AllEval/RaceEval with
  quoted data and unwrapped arrow function bodies
- Built-ins: yield* sleep(ms) → ExternalEval('sleep', [...])
- Sub-workflows: yield* fn(args) → Call (Strategy B)

Error detection (§11):
- E001 (let), E002 (var), E003 (reassignment), E004 (mutation),
  E005 (computed access), E006 (Math.random), E007 (Date.now),
  E008 (Map/Set), E009 (async), E010 (yield* in expr), E014 (eval),
  E017 (yield no *), E023 (non-Error throw), E024 (arrow block body),
  E028 (__ prefix), E029 (delete)

Tests (39 passing):
- All 3 spec end-to-end examples (§12.1, §12.2, §12.3)
- Expression compilation for every operator type
- Control flow: if/else, while, early return, throw
- Effects: agent calls, sleep, discarded effects
- Error detection: E001, E003, E005, E017, E023, E024, E028
- Validation: default-on kernel validate() on output
Moved all spec files from repo root to specs/:
- tisyn-specification-1.0.md
- tisyn-kernel-specification.md
- tisyn-agent-specification-1.1.0.md
- tisyn-compiler-specification-1.1.0.md
- tisyn-architecture.md
- tisyn-pingpong-specification.md

Removed duplicate demos/ping-pong/spec.md (canonical copy now in specs/).

Session-ID: ses_o4mni6hgrj
Session-ID: ses_o4mni6hgrj
Introduce the foundational @tisyn/ir package per the Authoring Layer
Specification v0.3.0. This zero-dependency package provides phantom-typed
Expr<T> constructors, grammar types, data shapes, classification,
walk/fold/transform traversal, print/decompile output, and collect
utilities. Existing packages (@tisyn/shared, @tisyn/compiler) now depend
on and re-export from @tisyn/ir.
Add GitHub Actions "Verify" workflow that runs lint, format check,
type check, and tests on push/PR to main. Apply oxfmt auto-formatting
to bring all files into compliance.
The TypeScript build requires Node type declarations for node:fs,
node:path, etc. These were resolved transitively locally but not
in CI.
6 replay tests covering hit, miss, divergence (wrong type, wrong name,
continue past close), and data-ignored replay. 1 journal test verifying
persist-before-resume ordering.
Apply oxfmt formatting to runtime test files and update pnpm-lock.yaml
to include vitest@3.2.4 for the runtime package.
Upgrade vitest from v3 to v4 across all packages. Replace manual
run() wrapping with @effectionx/bdd's native generator support in
test files that use effection (runtime and conformance packages).
Switch from @effectionx/bdd (node:test runner) to @effectionx/vitest
so Effection-aware tests run natively through vitest. This eliminates
the separate `node --test` step for crash-replay and runtime tests.

- Update imports from "@effectionx/bdd/node" to "@effectionx/vitest"
- Simplify test scripts to just "vitest run"
- Remove crash-replay.test.ts exclusion from conformance vitest config
- Add vitest config to runtime package to exclude dist/
Add compound effect interception in the runtime to orchestrate `all` and
`race` expressions with Effection structured concurrency. The kernel now
attaches parent env to compound descriptors so child kernels resolve refs
in the correct scope. The runtime strips __env at the orchestration
boundary, spawns child tasks with deterministic coroutineIds, and handles
cancellation journaling via ensure() with a closed-flag gate.

Includes substrate conformance, task orchestration, recovery/replay, and
cancellation tests (20 new tests), plus the compound concurrency spec.
1. Persist Close(cancelled) to durable stream — ensure() now uses a
   generator to yield* stream.append(), not just in-memory journal push.

2. Make orchestrateAll fail-fast — reject immediately on first child
   error instead of waiting for all N children to complete.

3. Add pre-close check for cancelled children during replay — skip
   kernel entirely for children with Close(cancelled) in replay index.
   Fix race replay test to use closeCancelled for loser.

4. Propagate DivergenceError fatally — re-throw after persisting
   Close(err) to stream. Update conformance harness to handle thrown
   DivergenceError in replay fixtures.

5. Add V1/V2 substrate conformance tests — V1 verifies all children
   close on scope exit, V2 verifies child closes precede post-compound
   effects in journal.
- resolve.ts: use sorted Object.keys() instead of unsorted Object.entries()
- guards.ts: reject empty string eval id in classifyNode
- guards.ts: reject extra fields on all tagged node types (eval/quote/ref/fn)
- Add NEG-021 and NEG-022 conformance fixtures
…1/E-1/L1

- Revert extra-field rejection in classifyNode (spec gap, not a bug)
- D-4: execute() catches DivergenceError and returns error result
- D-2: refactor __env to __tisyn_env/__tisyn_inner wrapper struct
- D-3: harness uses canonical byte comparison for journal events
- B-1: document race error selection spec inconsistency
- E-1: document canonical string encoding status
- L1: add KERN-050 (construct field order) and DET-002 (resolve key order) fixtures
Introduce agent(), operation(), implementAgent(), and Dispatch middleware
via effection/experimental createApi. Remove AgentRegistry entirely.

- agent(id, ops) declares a shared contract with host-side call methods
- implementAgent(decl, handlers) binds implementations as dispatch middleware
- Dispatch uses effection scope context instead of explicit registry passing
- Runtime execute() no longer accepts agents option; dispatch is contextual
- All runtime/conformance tests migrated from AgentRegistry to Dispatch.around
- Added @tisyn/agent test suite covering invocation, dispatch, and error cases
taras added 30 commits March 26, 2026 20:57
The compiler/runtime treat external effect data as a single payload
expression, but the public Eval() constructor accepted data as an array.
This caused print() to wrap non-array data in [...], changing program
semantics when switching between JSON and printed IR forms.

- Change Eval() signature from data: Expr<unknown>[] to data: Expr<unknown>
- Remove array wrapping in print() for external eval nodes
- Simplify decompileExternalCall() to always render single payload
- Update all test call sites to use single-payload convention
Implements the multi-agent-chat spec: a host-controlled chat loop
across two transport-backed agent boundaries (WebSocket browser agent,
Worker LLM agent) with a compiled workflow driving the orchestration.

- Compilable workflow using Browser, LLM, and State agent contracts
- Worker LLM agent with echo stub via workerTransport
- Browser agent via serverWebSocketTransport + installRemoteAgent
- Local State agent for cross-turn history accumulation
- Vanilla HTML browser client implementing JSON-RPC agent protocol
- Phased test suite: local agents → worker → websocket → end-to-end
Add *.generated.ts to .gitignore and remove the committed
workflow.generated.ts — it should be produced by build:workflow.
The generated workflow file should be committed so the example works
from a clean checkout. Build integration fixes still needed.
- Replace Call(chat as never) with Call(chat) in all 4 files now that
  Call() accepts TisynFn<A, R> directly
- Add extractMessage() runtime narrowing helper in phase4-e2e test
  instead of raw cast on protocol args payload
Case A (no-return while loops) was compiling each body statement as an
independent expression, discarding variable names from const declarations.
Now emits a single Let/Seq chain via emitStatementList, preserving
bindings within each iteration.

Removes the if (false) { return; } workaround from the chat demo that
was forcing Case B to get proper bindings.
Define Workflow<T> = Generator<unknown, T, unknown> so authored workflow
source can import the type and type-check in editors. The compiler
continues to recognize Workflow<T> by identifier name as before.

Removes workflow.ts from tsconfig exclude — it now type-checks cleanly.
…ports

- Rename LLM() to Llm() in workflow source for consistent PascalCase
- Simplify browser-transport and host wiring
- Move @effectionx/worker and @effectionx/node to dependencies
- Add reviewer topics for Case A bindings and spec alignment
- Add types: ["node"] to tsconfig, remove build-workflow exclusion
- Regenerate workflow.generated.ts
- Fix WebSocket message handling and channel close in browser transport
- Use once() for connection and close events instead of on()
- Add dev:browser script to serve browser client
- Update generated workflow: l-l-m.sample → llm.sample
…col stack

Convert the multi-agent-chat browser UI from a single static HTML file
with inline JS to a React app backed by the framework's own protocol
stack (implementAgent, createProtocolServer, @effectionx/websocket).

The browser now uses the same Effection-based agent pattern as the
LLM worker: implementAgent(Browser(), handlers) wired through
createProtocolServer over a WebSocket transport. A createQueue bridge
connects React's submit handler to the Effection waitForUser generator.

- Add React/Vite/websocket devDependencies (example-local only)
- Add tsconfig.browser.json for JSX/DOM support
- Add protocol-server sub-path export to @tisyn/transport to avoid
  bundling Node-only transports (stdio → ctrlc-windows) in the browser
- Add vitest.config.ts to prevent vitest inheriting Vite's browser root
- Add --journal <path> CLI flag for file-backed durable journaling (NDJSON)
- Reconstruct chat history from journal events on restart
- Filter CloseEvents on replay so terminated journals resume correctly
- Add structured logging with [scope] tags across host, browser transport,
  LLM worker, and state agent
- Optional DEBUG_CHAT=1 for verbose protocol-level logging
- Add hydrateTranscript and setReadOnly operations to Browser agent
- Hydrate browser transcript from host history on reconnect
- Show read-only state when workflow has already completed/failed
- Fix CTRL+C hang by providing connection resources via stream with
  explicit spawn/halt lifecycle management per iteration
- Use createSignal to map raw WebSocket connections to Operation<WebSocket>
  resources that clean up when their scope exits
- Add readOnlyRef to browser useChat to suppress false "Host shut down"
- Improve file journal error messages for malformed NDJSON
- Replace verbose JSON IR with builder functions in workflow.generated.ts
- Change LLM agent ID from "l-l-m" to "llm" to match workflow.generated.ts
- Replace import.meta.resolve() with new URL(_, import.meta.url).href for
  Vitest module runner compatibility
- Add BrowserSessionManager with identity-safe socket attach/detach,
  reconnect-safe waitForUser via withResolvers, and single-owner model
- Install Browser as local agent backed by session manager instead of
  installRemoteAgent with serverWebSocketTransport
- Replace JSON-RPC browser protocol with simple typed messages
- Simplify useChat.ts to raw WebSocket client with localStorage session ID
- Remove serverWebSocketTransport and JSON-RPC helpers from browser-transport
- Rewrite phase3 tests for session manager reconnect semantics
- Rewrite phase4 e2e test for new message protocol
- Fix host staying alive after workflow start with never-resolving yield
… agents

- Rename phase1-4 test files to behavior-based names (workflow, worker-transport, browser-session, e2e)
- Extract repeated agent declarations into test/helpers/agents.ts
- Remove phase language from describe titles and JSDoc comments
Implement the tisyn-browser-test-orchestration spec with 4 Playwright-
based acceptance tests driven as Tisyn workflows:

- basicSendReceive
- transcriptRestoresAfterReload
- hostRestartPreservesState
- secondBrowserIsReadOnly

Prerequisites: add ARIA roles/labels for accessible selectors, make
host port configurable (--port flag), derive useChat WS URL from
window.location, and add a runner-owned reverse proxy for static
files + WS forwarding.

Test infrastructure uses @effectionx/process daemon for host lifecycle,
@effectionx/converge for readiness polling, and per-test isolation
(fresh process, proxy, browser context, and temp journal).
Remove tsx from the browser-test-critical path by using
node --experimental-strip-types for build-workflow.ts and
build-test-workflows.ts. Host runtime stays on emitted JS
(node dist/host.js via tsc emit).

Also includes:
- tsc emit config (tsconfig.build.json) for host runtime
- Host startup diagnostics (stderr/stdout capture on failure)
- Local-listen preflight check in browser acceptance suite
- Workflow file imports aligned with compiler requirements
The compiler already generates identical agent declarations in
workflow.generated.ts and workflows.generated.ts. Remove the
redundant hand-written agents.ts files and update all consumers
to import factory functions from the generated modules.
- runScenario() accepts TisynFn<[], unknown> instead of an Effection
  function, removing the Call(workflow as any) cast
- Handler factories derive types from generated TestBrowser()/TestHost()
  via ImplementationHandlers, eliminating manual { input: ... } shapes
Use resource + withResolvers for the WebSocket readiness probe,
ensuring ws.close() runs on cancellation, success, and error.
Removes manual setTimeout and new Promise wrapper.
- Build scripts: readFileSync/writeFileSync/readdirSync → fs/promises
  with top-level await
- FileJournalStream: async readFile/writeFile with flush:true for
  durability (fsync before resolve)
- Proxy: async readFile in HTTP handler with try/catch for ENOENT
- Scenario: mkdtempSync/rmSync → async via call()
- Tests: unlinkSync → async unlink, readFileSync → async readFile
Replace fine-grained host-visible browser operations (fill, click,
expect*) with a single Browser().execute({ workflow: <ir-payload> })
that sends compiled DOM workflow IR to run inside the browser page
via a local Dom agent and Tisyn executor.

- Two-pass build: dom workflows (JSON IR) then host workflows
- In-page executor bundled as IIFE via Vite, injected per page load
- Dom agent uses @testing-library/dom for semantic queries with retry
- Host workflows reference dom IR as free variables (Ref → env lookup)
- Fix ECONNRESET in proxy during host restart tests
- Delete manual createDomAgent(); import generated Dom() instead
- Cast input.workflow to unknown at page.evaluate boundary to
  avoid excessively deep type instantiation from IrInput union
Add a Vite plugin that proxies WebSocket upgrades to the host on
port 3000, restoring the dual-process demo workflow (host + Vite
dev server). Skip Vite HMR connections via sec-websocket-protocol
header check.
Rename the app workflow's Browser agent to App to better reflect
its role. Test-side Browser agent (Playwright sessions) unchanged.
Also update reconstructHistory and journal test fixtures to match
the new "app" agent ID.
…t ops

Consolidate browser session management into the Chat agent. Replace
App().waitForUser/showAssistantMessage/hydrateTranscript and State agent
with Chat().elicit/renderTranscript/setReadOnly. History is now
managed workflow-locally rather than via a separate State agent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant