chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,10 @@
**/node_modules
.next
.git
**/vitest.config.ts
**/test-setup.ts
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
tests/e2e
@@ -0,0 +1,17 @@
# API Keys (shared across integrations)
OPENAI_API_KEY=replace-with-your-key
ANTHROPIC_API_KEY=replace-with-your-key
# Optional: route OpenAI calls through a proxy (e.g. the showcase aimock
# server for deterministic record/replay). When unset, calls go to OpenAI.
# OPENAI_BASE_URL=http://localhost:5555/v1
# Optional: model provider + id for the Strands agent (defaults: openai / gpt-4o).
# MODEL_PROVIDER=openai
# MODEL_ID=gpt-4o
# Agent backend URL (for the CopilotKit runtime proxy)
AGENT_URL=http://localhost:8000
# Showcase
NEXT_PUBLIC_BASE_URL=http://localhost:3000
@@ -0,0 +1,8 @@
# Sample multimodal demo binaries (PNG + PDF) must stay as regular
# binaries — not LFS pointers — so deploy environments without `git lfs pull`
# (Railway, in particular) serve the actual files. LFS tracking for other
# PNG/PDFs under this package inherits from the repo-root .gitattributes.
public/demo-files/sample.png -filter -diff -merge binary
public/demo-files/sample.pdf -filter -diff -merge binary
# Voice demo sample audio follows the same convention.
public/demo-audio/sample.wav -filter -diff -merge binary
@@ -0,0 +1,13 @@
node_modules/
.next/
.env.local
.env
*.pyc
__pycache__/
.venv/
dist/
playwright-report/
test-results/
# Shared modules (copied by CI)
shared_frontend/
@@ -0,0 +1,63 @@
# Stage 1: Build Next.js frontend
FROM node:22-slim AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps
COPY . .
RUN npx next build
# Stage 2: Install agent (Strands TS) dependencies
FROM node:22-slim AS agent-deps
WORKDIR /agent
COPY src/agent/package.json src/agent/package-lock.json ./
# `npm ci` without --omit=dev because the agent uses `tsx` as an ESM loader
# (not a watcher) at runtime via `node --import tsx server.ts` — tsx must be
# resolvable at boot. This still excludes top-level devDeps (only @types/*),
# so node_modules stays lean.
RUN npm ci --legacy-peer-deps
# Stage 3: Production image — runtime only (no build tools)
FROM node:22-slim AS runner
WORKDIR /app
# curl — entrypoint.sh watchdog probes the agent's /health endpoint with it.
# node:22-slim does NOT ship curl by default.
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Unprivileged runtime user created BEFORE any COPY so --chown resolves by
# name and no recursive chown over /app is ever needed (fast builds).
RUN (groupadd --system --gid 1001 app 2>/dev/null || true) \
&& (useradd --system --uid 1001 --gid 1001 --no-create-home app 2>/dev/null || true) \
&& mkdir -p /home/app && chown app:app /home/app
# Next.js build artifacts
COPY --chown=app:app --from=frontend /app/.next ./.next
COPY --chown=app:app --from=frontend /app/node_modules ./node_modules
COPY --chown=app:app --from=frontend /app/package.json ./
COPY --chown=app:app --from=frontend /app/public ./public
# Agent code + prod-only deps (installed in agent-deps stage, not at runtime)
COPY --chown=app:app --from=agent-deps /agent/node_modules ./src/agent/node_modules
COPY --chown=app:app src/agent ./src/agent
COPY --chown=app:app src/cvdiag ./src/cvdiag
# Entrypoint
COPY --chown=app:app entrypoint.sh ./
RUN chmod +x entrypoint.sh
# Ensure WORKDIR itself is owned by `app` — `WORKDIR /app` creates /app as
# root, and `COPY --chown` only reassigns copied files, not the parent dir.
# Without this, any runtime mkdir under /app (Next.js caches, etc.) hits
# EACCES under the unprivileged user.
RUN chown app:app /app
USER app
EXPOSE 10000
# Intentionally NOT setting `ENV NODE_ENV=production` at the image level — it
# would leak into every child process (the TS agent via the tsx ESM loader,
# shell scripts, healthchecks). entrypoint.sh scopes NODE_ENV=production to
# the Next.js invocation only.
ENV PORT=10000
ENV HOSTNAME=0.0.0.0
CMD ["./entrypoint.sh"]
@@ -0,0 +1,43 @@
{
"framework": "strands-typescript",
"features": {
"agentic-chat": {
"og_docs_url": "https://docs.copilotkit.ai/aws-strands/prebuilt-components",
"shell_docs_path": "/prebuilt-components"
},
"hitl-in-chat": {
"og_docs_url": "https://docs.copilotkit.ai/human-in-the-loop",
"shell_docs_path": "/generative-ui/your-components/interactive"
},
"tool-rendering": {
"og_docs_url": "https://docs.copilotkit.ai/aws-strands/generative-ui/tool-rendering",
"shell_docs_path": "/generative-ui/tool-rendering"
},
"gen-ui-tool-based": {
"og_docs_url": "https://docs.copilotkit.ai/aws-strands/generative-ui/your-components/display-only",
"shell_docs_path": "/generative-ui/your-components/display-only"
},
"gen-ui-agent": {
"og_docs_url": "https://docs.copilotkit.ai/aws-strands/generative-ui/state-rendering",
"shell_docs_path": "/generative-ui/state-rendering"
},
"shared-state-read-write": {
"og_docs_url": "https://docs.copilotkit.ai/aws-strands/shared-state/in-app-agent-write",
"shell_docs_path": "/shared-state"
},
"shared-state-streaming": {
"og_docs_url": "https://docs.copilotkit.ai/shared-state",
"shell_docs_path": "/shared-state"
},
"subagents": {
"og_docs_url": "https://docs.copilotkit.ai/aws-strands",
"shell_docs_path": "/multi-agent/subagents"
}
},
"missing": [
{
"feature": "all-shell-paths",
"reason": "shell-docs has no strands-scoped tree. Shell paths point at framework-agnostic content rendered under /strands/unselected/... with snippets resolved for the framework when tagged."
}
]
}
+377
View File
@@ -0,0 +1,377 @@
#!/bin/bash
set -e
# ---------------------------------------------------------------------------
# Agent process-tree kill.
#
# The agent is launched as a compound command through a process substitution:
# cd /app/src/agent && ... npm start &> >(awk …) &
# so $AGENT_PID (=$!) is the *outer subshell* wrapping that pipeline — NOT the
# `npm` wrapper nor the `node` server it forks. A plain `kill -9 $AGENT_PID`
# therefore reaps only the subshell: `npm` and `node` are reparented to PID 1
# and KEEP RUNNING — still bound to :8000. The watchdog's whole promise
# ("kill agent → wait -n returns → container restart") is then broken: the
# frontend proxies to a dead-but-not-restarted agent forever (edge 502s), and
# even if the container does exit, a surviving orphan can still hold :8000
# across the restart.
#
# We cannot `kill -- -$PGID` because a non-interactive script has job control
# OFF: the agent subshell, npm, node, next.js AND the main shell all share the
# shell's process group, so a group kill would take out the whole entrypoint.
# Instead we walk the process tree via /proc (node:22-slim ships neither
# `ps` nor `pgrep`) and SIGKILL every descendant, deepest-first, in a BOUNDED
# re-scan loop that keeps the root alive as the walk anchor until the subtree
# is drained, then kills the root last (see _kill_agent_tree for why).
#
# Defined ABOVE cleanup() on purpose: cleanup() (the EXIT/SIGTERM trap) calls
# _kill_agent_tree, so the helper must already exist whenever the trap can
# first fire — including the early `exit 1` below if the agent fails to start.
# ---------------------------------------------------------------------------
_agent_descendants() {
# Print all descendant PIDs of $1 (children, grandchildren, …), deepest-first.
local root="$1" pid ppid stat _state _rest
# Fail closed on a dangerous or meaningless root. An empty / non-numeric root
# would make the PPID comparison below match nothing (harmless) but a root of
# "0" or "1" is catastrophic: "0" means "every process in the caller's process
# group" and "1" is init — a caller that then fed the result to a kill could
# wipe the whole container. Refuse anything that is not an integer >= 2.
case "$root" in
''|*[!0-9]*) echo "[proctree] WARNING: refusing descendant scan for non-numeric root '${root}'" >&2; return 0 ;;
esac
if [ "$root" -le 1 ]; then
echo "[proctree] WARNING: refusing descendant scan for reserved root ${root} (0=process-group, 1=init)" >&2
return 0
fi
for pid in $(cd /proc 2>/dev/null && ls -d [0-9]* 2>/dev/null); do
[ -r "/proc/$pid/stat" ] || continue
# /proc/PID/stat is: "PID (comm) STATE PPID PGRP …". comm can contain
# spaces AND parens, so strip through the final ") " before splitting; PPID
# is then the 2nd field of the remainder (1st is STATE). "${x##*) }" takes
# the LONGEST prefix up to the LAST ") ", and no field after the real
# closing paren contains ")", so even a comm like "(evil) S 1)" parses to
# the true PPID — the last ") " is always the comm's real terminator.
stat=$(cat "/proc/$pid/stat" 2>/dev/null) || continue
# The remainder after the comm's terminating ") " is "STATE PPID PGRP …", so
# PPID is the 2nd whitespace-separated field. Use the `read` builtin instead
# of `echo … | awk` to avoid forking awk once per /proc entry (a fork-storm
# under a large process table). `read` word-splits on IFS; discard STATE
# into _state, capture PPID, discard the rest into _rest.
read -r _state ppid _rest <<< "${stat##*) }"
if [ "$ppid" = "$root" ]; then
_agent_descendants "$pid"
echo "$pid"
fi
done
}
_kill_agent_tree() {
# SIGKILL the agent subshell AND its npm→node descendants so the real server
# actually dies and frees :8000 — not just the log-pipeline subshell.
#
# A single snapshot-then-kill is racy: a descendant that forks a new child (or
# a child that reparents) BETWEEN the scan and the kill is missed by the walk,
# reparents to PID 1, and keeps :8000 bound — defeating the whole tree-kill.
# So we re-scan in a BOUNDED loop, killing the currently-live descendants
# deepest-first each pass, until a scan comes back empty (or the bound is
# hit). Crucially we keep the ROOT alive as the walk anchor across passes and
# kill it LAST: killing root first would immediately reparent every descendant
# to PID 1, making them unreachable by a root-anchored PPID walk. Leaving root
# alive (it is an idle subshell that spawns nothing on its own) means a child
# that forks between two passes is still attached to a live chain from root
# and is reaped on the next pass.
#
# Residual limitation: a descendant that FULLY reparents to PID 1 (double-fork
# / daemonize) before we reach it is no longer on any PPID chain from root and
# cannot be found by a /proc PPID walk. That is inherent to PPID-based reaping
# without job control (no ps/pgrep in node:22-slim; job control off in a
# non-interactive script, so no process-group kill). The agent's npm→node tree
# does not daemonize, so this loop covers the real failure surface.
#
# Fail closed on a dangerous or meaningless root, BEFORE any kill runs. If the
# caller passes an empty / non-numeric PID, or the reserved 0 (SIGKILL to the
# WHOLE caller process group) or 1 (init), refuse outright — a bare `kill -9 0`
# here would SIGKILL the entire entrypoint. This makes `kill -9 0`/`kill -9 1`
# structurally impossible regardless of what the caller passes.
local root="$1" p descendants
case "$root" in
''|*[!0-9]*) echo "[proctree] WARNING: refusing tree-kill for non-numeric PID '${root}'" >&2; return 0 ;;
esac
if [ "$root" -le 1 ]; then
echo "[proctree] WARNING: refusing tree-kill for reserved PID ${root} (0=process-group, 1=init)" >&2
return 0
fi
for _ in 1 2 3 4 5; do
descendants=$(_agent_descendants "$root")
[ -z "$descendants" ] && break
for p in $descendants; do
kill -9 "$p" 2>/dev/null || true
done
# `|| true`: under set -e a non-zero `sleep` (e.g. a future busybox/Alpine
# rebase whose sleep can fail) would abort this tree-kill mid-walk, leaving
# the root un-killed and the real npm→node server orphaned. The guard keeps
# the walk running to completion regardless of sleep's exit status.
sleep 0.2 || true
done
kill -9 "$root" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# Numeric-config validator.
#
# Every operator-overridable numeric knob (size threshold, check intervals,
# strike budgets, grace/timeout windows) is read from an env var with a `:-`
# default. A non-integer or empty override (e.g. LANGGRAPH_SIZE_CHECK_INTERVAL
# ="60s") does NOT fall back to the default on its own — it propagates as a bad
# value into an arithmetic test (`[ .. -ge .. ]`), a `sleep`, or a loop count.
# Under `set -e` those failures are inconsistent: a bad `sleep $INTERVAL` makes
# the size-monitor loop exit on its FIRST iteration, silently disabling the
# whole size guard for the container's lifetime; a bad arithmetic test inside an
# `if` evaluates false and skips the guard with no warning. Either way an
# operator typo silently DISABLES a guard.
#
# _require_int validates ONE such var by name and rewrites it in place: if the
# current value is a positive integer it is kept; otherwise a WARNING is logged
# and the documented default is substituted. It fails SAFE — it never aborts
# and never leaves a guard fed by a bad value. Run over EVERY numeric config
# var at startup (see the validation pass below) so no `sleep`/loop-count/
# arithmetic test downstream can be silently broken by a bad override.
#
# Args: $1 = variable NAME (validated + reassigned via printf -v)
# $2 = documented default (used verbatim on fallback)
# $3 = human label for the warning
_require_int() {
local name="$1" default="$2" label="$3" value
eval "value=\${$name}"
# Valid ONLY if a positive integer with no leading zero: first digit 1-9,
# rest digits ([1-9][0-9]*). This rejects — and falls back to the default
# for — every operator-typo class that would break a guard:
# • empty / non-numeric ("", "60s", "abc")
# • "0" — a zero threshold/interval/limit turns a guard into an instant-fire
# kill loop (SIZE_THRESHOLD_MB=0 kills on cycle 1) or a busy-spin
# (SIZE_CHECK_INTERVAL=0 → `while sleep 0`)
# • leading-zero / octal forms ("010", "08") — bash arithmetic reads a
# leading-zero literal as OCTAL, so "010" becomes 8 (wrong value) and an
# "08"/"09" digit aborts the script under `set -e` ("value too great for
# base").
# The `[1-9]*([0-9])` extglob-free equivalent is written as two arms: a lone
# single digit 1-9, or a 1-9 lead followed by one-or-more digits.
#
# UPPER BOUND (fail-safe, same class as the checks above): even a value that is
# syntactically all-digits can be pathologically large. bash arithmetic is
# signed 64-bit, so a 19-digit value can still parse but a 20+ digit value
# OVERFLOWS — `[ "$x" -ge "$y" ]` then aborts with "value too great for base"
# (suppressed to false inside an `if`, silently disabling the guard) or wraps
# to a negative/garbage magnitude. No real knob (interval seconds, strike
# count, size-MB threshold) is ever more than a handful of digits, so cap the
# length at 10 digits (max 9,999,999,999 — years of seconds, petabytes of MB;
# comfortably inside the signed-64-bit range with no overflow risk). A longer
# value is treated exactly like any other bad override: WARN + fall back to the
# documented default rather than silently disabling the guard.
if [ "${#value}" -gt 10 ]; then
echo "[entrypoint] WARNING: ${label} (${name}) is too large (got: '${value}', ${#value} digits — max 10) — falling back to default ${default}"
printf -v "$name" '%s' "$default"
return 0
fi
case "$value" in
[1-9]) : ;; # single positive digit
[1-9][0-9]*) # multi-digit, must be all digits after the lead
case "$value" in
*[!0-9]*)
echo "[entrypoint] WARNING: ${label} (${name}) is not a positive integer (got: '${value}') — falling back to default ${default}"
printf -v "$name" '%s' "$default"
;;
esac
;;
*)
echo "[entrypoint] WARNING: ${label} (${name}) is not a positive integer (got: '${value}') — falling back to default ${default}"
printf -v "$name" '%s' "$default"
;;
esac
}
cleanup() {
# Tree-kill the agent (not a bare `kill $AGENT_PID`): $AGENT_PID is the
# process-sub subshell, so a single-PID kill on the normal shutdown path
# (graceful exit / SIGTERM on every Railway redeploy/rollover) would reap
# only the subshell and ORPHAN the real npm→node server — reparented to
# PID 1, still holding :8000 across the restart. See _kill_agent_tree.
_kill_agent_tree "$AGENT_PID"
# Tree-kill Next.js too: NEXTJS_PID is ALSO a process-sub subshell wrapping
# `npx next start` (which forks npm→node), exactly like AGENT_PID. A bare
# `kill $NEXTJS_PID` would reap only the wrapper subshell and ORPHAN the real
# Next.js node server — reparented to PID 1, still holding $PORT across the
# Railway redeploy/rollover SIGTERM, so the new container cannot bind $PORT.
# (Same orphan class already fixed for the agent; route the frontend through
# the same guarded walk.) WATCHDOG_PID is a genuine single-PID subshell we
# spawn directly (`( … ) &`, not process-sub-wrapped) that forks nothing that
# outlives it, so a bare kill is correct for it.
_kill_agent_tree "$NEXTJS_PID"
kill $WATCHDOG_PID 2>/dev/null || true
}
trap cleanup EXIT
echo "========================================="
echo "[entrypoint] Starting showcase package: strands-typescript"
echo "[entrypoint] Time: $(date -u)"
echo "[entrypoint] PORT=${PORT:-not set}"
echo "[entrypoint] NODE_ENV=${NODE_ENV:-not set}"
echo "========================================="
if [ -z "$OPENAI_API_KEY" ]; then
echo "[entrypoint] WARNING: OPENAI_API_KEY is not set! Agent will fail."
else
echo "[entrypoint] OPENAI_API_KEY: set (${#OPENAI_API_KEY} chars)"
fi
# Start the Strands TS agent server on :8000.
# `npm start` runs `node --import tsx server.ts` (see src/agent/package.json).
# tsx is a one-shot ESM loader here (NOT a watcher) so server.ts and its
# imports resolve without a precompile step. Log prefixing uses bash process
# substitution (`&> >(awk …)`) rather than a pipe so `$!` (captured below as
# AGENT_PID) refers to the agent's own launch process and NOT the awk log-
# formatter — `wait -n $AGENT_PID` thus monitors the agent side, not the log
# pipeline. Note `$!`/AGENT_PID is the WRAPPING SUBSHELL of the `... &` compound
# command, NOT the real npm→node server it forks (the server is a DESCENDANT,
# reached only via the tree-kill — see the file header and _kill_agent_tree).
# Never `kill $AGENT_PID` directly: that reaps only the wrapper subshell and
# orphans the real server on :8000.
echo "[entrypoint] Starting Strands TS agent on port 8000..."
cd /app/src/agent && PORT=8000 HOST=0.0.0.0 npm start &> >(awk '{print "[agent] " $0; fflush()}') &
AGENT_PID=$!
cd /app
sleep 3
if kill -0 $AGENT_PID 2>/dev/null; then
echo "[entrypoint] Agent server started (PID: $AGENT_PID)"
else
echo "[entrypoint] ERROR: Agent server failed to start — exiting"
exit 1
fi
echo "========================================="
echo "[entrypoint] Starting Next.js frontend on port ${PORT:-10000}..."
echo "========================================="
PORT=${PORT:-10000}
# Scope NODE_ENV=production to the Next.js invocation ONLY, not the whole
# container environment. `ENV NODE_ENV=production` at the image level would
# leak into every child process (agent, shell, healthchecks). `env` prefix
# binds the value to this single exec.
env NODE_ENV=production npx next start --port $PORT &> >(awk '{print "[nextjs] " $0; fflush()}') &
NEXTJS_PID=$!
echo "[entrypoint] Next.js started (PID: $NEXTJS_PID)"
# Watchdog: Railway deploys of showcase packages have been observed to hit a
# silent agent hang — the process stays alive (so `wait -n` never fires and
# the container never restarts) but stops responding on :8000. Poll
# /health every 30s; after 3 consecutive failures (~90s unreachable), kill
# the agent so `wait -n` returns and Railway restarts the container.
#
# Startup grace: the Strands TS agent does a tsx cold-start (one-shot ESM
# compile of server.ts + imports on first boot). On fresh Railway containers
# this can exceed the 90s (3-strike) budget, and — now that the agent kill is
# effective via the process-tree walk above (previously the orphan bug made it
# cosmetic) — a slow boot would be genuinely killed and enter a restart loop.
# Wait up to 180s for the first healthy /health probe before arming the strike
# counter; if /health comes up sooner, fall through immediately. If 180s
# elapses without success, arm the counter anyway — the steady-state watchdog
# will handle a true hang. Mirrors langgraph-typescript/entrypoint.sh.
#
# Startup grace window, steady-state health-probe interval and strike budget.
# All operator-overridable so deploy tuning does not require an image rebuild.
STARTUP_GRACE_SECONDS=${STRANDS_STARTUP_GRACE_SECONDS:-180}
HEALTH_CHECK_INTERVAL=${STRANDS_HEALTH_CHECK_INTERVAL:-30}
HEALTH_STRIKE_LIMIT=${STRANDS_HEALTH_STRIKE_LIMIT:-3}
# ---------------------------------------------------------------------------
# Numeric-config validation pass (CLASS 1 structural guard).
#
# Validate EVERY operator-overridable numeric knob at STARTUP, before any of
# them can feed a `sleep`, a loop count, or an arithmetic test. A bad override
# (non-integer / empty) on ANY of these would otherwise silently disable a guard
# — e.g. STRANDS_HEALTH_CHECK_INTERVAL="30s" makes the very first
# `while sleep $HEALTH_CHECK_INTERVAL` fail and kills the health monitor loop
# for the container's lifetime. Each bad value WARNs and falls back to the
# documented default (fail-safe: never abort, never leave a guard disabled).
_require_int STARTUP_GRACE_SECONDS 180 "Strands startup grace window (s)"
_require_int HEALTH_CHECK_INTERVAL 30 "Strands health-probe interval (s)"
_require_int HEALTH_STRIKE_LIMIT 3 "Strands health strike limit"
(
GRACE=$STARTUP_GRACE_SECONDS
echo "[watchdog] Startup grace: waiting up to ${GRACE}s for first successful health probe before arming strike counter"
ELAPSED=0
while [ $ELAPSED -lt $GRACE ]; do
if ! kill -0 $AGENT_PID 2>/dev/null; then
# Agent died during startup — wait -n in the main shell will handle it.
exit 0
fi
if curl -fsS --max-time 5 http://127.0.0.1:8000/health > /dev/null 2>&1; then
echo "[watchdog] Agent healthy after ${ELAPSED}s — arming strike counter"
break
fi
sleep 5
ELAPSED=$((ELAPSED + 5))
done
if [ $ELAPSED -ge $GRACE ]; then
echo "[watchdog] Grace window elapsed without successful probe — arming strike counter anyway"
fi
FAILS=0
while sleep "$HEALTH_CHECK_INTERVAL"; do
if ! kill -0 $AGENT_PID 2>/dev/null; then
break
fi
if curl -fsS --max-time 5 http://127.0.0.1:8000/health > /dev/null 2>&1; then
FAILS=0
else
FAILS=$((FAILS + 1))
echo "[watchdog] Agent health probe failed (count=$FAILS)"
if [ $FAILS -ge "$HEALTH_STRIKE_LIMIT" ]; then
echo "[watchdog] Agent unresponsive for ~$((HEALTH_CHECK_INTERVAL * HEALTH_STRIKE_LIMIT))s — killing PID $AGENT_PID (and its npm→node tree) to trigger container restart"
# Tree-kill (not a bare `kill -9 $AGENT_PID`): $AGENT_PID is the process-sub
# subshell, so a single-PID kill would orphan the real npm→node server,
# leaving :8000 bound to a hung agent that `wait -n` never observes dying.
_kill_agent_tree "$AGENT_PID"
break
fi
fi
done
) &
WATCHDOG_PID=$!
echo "[entrypoint] Watchdog started (PID: $WATCHDOG_PID, probing http://127.0.0.1:8000/health, startup grace ${STARTUP_GRACE_SECONDS}s)"
echo "[entrypoint] All processes running. Waiting..."
# Only wait on agent + next.js — NOT the watchdog.
#
# `|| EXIT_CODE=$?` is LOAD-BEARING under `set -e`: the PRIMARY designed exit
# path here is a NON-ZERO wait (137 = the watchdog SIGKILL of the agent tree,
# or an agent crash). Without the `||` guard, `set -e` aborts the script AT
# this line on exactly those interesting exits, making the entire "which
# process exited with code N" diagnostic below AND the final explicit
# `exit $EXIT_CODE` dead code — the container still restarts (EXIT trap runs
# cleanup, script exits non-zero) but the operator-facing diagnostic never
# prints. Capturing the code preserves it exactly (incl. 137) for both the
# diagnostic and the final exit, and the container-restart path is unchanged.
#
# REAPED_PID via `wait -n -p VAR` (bash >= 5.1; node:22-slim ships 5.2) captures
# the ACTUAL PID `wait -n` reaped. The previous `kill -0` if/elif merely INFERRED
# which process exited by probing liveness AFTER the wait — racy on a near-
# simultaneous exit: if both are dead by the time we probe, the first `kill -0`
# branch always wins and mislabels the diagnostic (e.g. reports the agent when
# Next.js is what actually exited, attaching the wrong code to the wrong name).
# Keying the message off the reaped PID names the correct process every time.
# `|| EXIT_CODE=$?` remains LOAD-BEARING (see above): -p does not change that a
# non-zero wait would abort under set -e without the guard.
REAPED_PID=""
EXIT_CODE=0
wait -n -p REAPED_PID "$AGENT_PID" "$NEXTJS_PID" || EXIT_CODE=$?
if [ "$REAPED_PID" = "$AGENT_PID" ]; then
echo "[entrypoint] Agent (PID: $AGENT_PID) exited with code $EXIT_CODE"
elif [ "$REAPED_PID" = "$NEXTJS_PID" ]; then
echo "[entrypoint] Next.js (PID: $NEXTJS_PID) exited with code $EXIT_CODE"
else
echo "[entrypoint] A process (PID: ${REAPED_PID:-unknown}) exited with code $EXIT_CODE"
fi
exit $EXIT_CODE
@@ -0,0 +1,571 @@
name: AWS Strands (TypeScript)
slug: strands-typescript
category: emerging
language: typescript
logo: /logos/strands-typescript.svg
description: >-
TypeScript variant of the AWS Strands integration. Uses
@strands-agents/sdk to define an agent with tool calling, shared state,
and human-in-the-loop, connected to CopilotKit via the @ag-ui/aws-strands
adapter.
managed_platform:
name: AWS Bedrock AgentCore
url: https://aws.amazon.com/bedrock/agents/
partner_docs: null
repo: https://github.com/CopilotKit/CopilotKit/tree/main/showcase/integrations/strands-typescript
copilotkit_version: 2.0.0
deployed: true
docs_mode: generated
sort_order: 131
generative_ui:
- constrained-explicit
- a2ui-fixed-schema
- a2ui-dynamic-schema
interaction_modalities:
- sidebar
- embedded
- chat
not_supported_features:
- shared-state-streaming
# QUARANTINED pending a @copilotkit/react-core release. gen-ui-interrupt /
# interrupt-headless fail turn-2 on a useInterrupt/useHeadlessInterrupt
# RESUME-PATH hook bug in @copilotkit/react-core/v2: backend resumes + streams
# (HTTP 200) but the frontend never appends the confirmation assistant bubble,
# so the harness DOM settle-check times out. Fix is a published-package change
# (out of scope here); honestly marked not-supported (skipped-incapable, not
# green, not red) rather than a regression. Demos remain wired below.
- gen-ui-interrupt
- interrupt-headless
- reasoning-default-render
- agentic-chat-reasoning
- tool-rendering-reasoning-chain
features:
- cli-start
- agentic-chat
- chat-customization-css
- prebuilt-sidebar
- prebuilt-popup
- chat-slots
- headless-simple
- headless-complete
- frontend-tools
- frontend-tools-async
- hitl
- hitl-in-chat
- hitl-in-chat-booking
- hitl-in-app
- tool-rendering
- tool-rendering-default-catchall
- tool-rendering-custom-catchall
- gen-ui-agent
- gen-ui-tool-based
- declarative-gen-ui
- a2ui-fixed-schema
- a2ui-recovery
- shared-state-read-write
- readonly-state-agent-context
- subagents
- multimodal
- voice
- auth
- agent-config
- declarative-hashbrown
- declarative-json-render
- open-gen-ui
- open-gen-ui-advanced
- mcp-apps
- beautiful-chat
- shared-state-read
a2ui_pattern: llm-driven
interrupt_pattern: promise-based
agent_config_pattern: shared-state
auth_pattern: runtime-onrequest
demos:
- id: cli-start
name: CLI Start Command
description: Copy-paste command to clone the canonical starter
tags:
- chat-ui
command: "npx copilotkit@latest init --framework strands-typescript"
- id: agentic-chat
name: Agentic Chat
description: Natural conversation with frontend tool execution
tags:
- chat-ui
route: /demos/agentic-chat
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/agentic-chat/page.tsx
- src/app/api/copilotkit/route.ts
- id: chat-customization-css
name: Chat Customization (CSS)
description: Default CopilotChat re-themed via CopilotKitCSSProperties
tags:
- chat-ui
route: /demos/chat-customization-css
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/chat-customization-css/page.tsx
- src/app/demos/chat-customization-css/theme.css
- src/app/api/copilotkit/route.ts
- id: prebuilt-sidebar
name: "Pre-Built: Sidebar"
description: Docked sidebar chat via <CopilotSidebar />
tags:
- chat-ui
route: /demos/prebuilt-sidebar
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/prebuilt-sidebar/page.tsx
- src/app/api/copilotkit/route.ts
- id: prebuilt-popup
name: "Pre-Built: Popup"
description: Floating popup chat via <CopilotPopup />
tags:
- chat-ui
route: /demos/prebuilt-popup
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/prebuilt-popup/page.tsx
- src/app/api/copilotkit/route.ts
- id: chat-slots
name: Chat Customization (Slots)
description: Customize CopilotChat via its slot system
tags:
- chat-ui
route: /demos/chat-slots
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/chat-slots/page.tsx
- src/app/api/copilotkit/route.ts
- src/app/demos/chat-slots/slot-wrappers.tsx
- id: headless-simple
name: Headless Chat (Simple)
description: Minimal custom chat surface built on useAgent
tags:
- chat-ui
route: /demos/headless-simple
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/headless-simple/page.tsx
- src/app/api/copilotkit/route.ts
- id: headless-complete
name: Headless Chat (Complete)
description: Full chat implementation built from scratch on useAgent
tags:
- chat-ui
route: /demos/headless-complete
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/headless-complete/page.tsx
- src/app/api/copilotkit/route.ts
- src/app/demos/headless-complete/chat/chat.tsx
- src/app/demos/headless-complete/hooks/use-tool-renderers.tsx
- src/app/demos/headless-complete/hooks/use-frontend-components.ts
- src/app/demos/headless-complete/hooks/use-headless-suggestions.ts
- src/app/demos/headless-complete/attachments/use-attachments-config.ts
- src/app/demos/headless-complete/tools/weather-card.tsx
- src/app/demos/headless-complete/tools/stock-card.tsx
- src/app/demos/headless-complete/tools/chart-card.tsx
- src/app/demos/headless-complete/tools/highlight-note.tsx
- id: frontend-tools
name: Frontend Tools (In-App Actions)
description: Agent invokes client-side handlers registered with useFrontendTool
tags:
- interactivity
route: /demos/frontend-tools
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/frontend-tools/page.tsx
- src/app/api/copilotkit/route.ts
- id: frontend-tools-async
name: Frontend Tools (Async)
description: useFrontendTool with an async handler
tags:
- interactivity
route: /demos/frontend-tools-async
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/frontend-tools-async/page.tsx
- src/app/demos/frontend-tools-async/notes-card.tsx
- src/app/api/copilotkit/route.ts
- id: hitl-in-chat
name: In-Chat HITL (useHumanInTheLoop)
description: Inline approval/decision surface via the ergonomic useHumanInTheLoop hook
tags:
- interactivity
route: /demos/hitl-in-chat
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/hitl-in-chat/page.tsx
- src/app/demos/hitl-in-chat/time-picker-card.tsx
- src/app/api/copilotkit/route.ts
- id: hitl-in-chat-booking
name: In-Chat HITL (Booking)
description: Time-picker card rendered inline via useHumanInTheLoop for a booking flow
tags:
- interactivity
route: /demos/hitl-in-chat
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/hitl-in-chat/page.tsx
- src/app/demos/hitl-in-chat/time-picker-card.tsx
- src/app/api/copilotkit/route.ts
- id: hitl-in-app
name: In-App Human in the Loop
description:
Agent requests approval via async useFrontendTool; UI pops as an
app-level modal
tags:
- interactivity
route: /demos/hitl-in-app
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/hitl-in-app/page.tsx
- src/app/demos/hitl-in-app/approval-dialog.tsx
- src/app/api/copilotkit/route.ts
- id: hitl
name: In-Chat HITL (Steps)
description: Agent pauses for user approval via step-based human-in-the-loop
tags:
- interactivity
route: /demos/hitl
animated_preview_url:
highlight:
- src/app/demos/hitl/page.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering
name: Tool Rendering
description: Custom render for tool calls inline in the chat stream
tags:
- generative-ui
route: /demos/tool-rendering
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/agent/tools.ts
- src/agent/tools.ts
- src/agent/tools.ts
- src/agent/tools.ts
- src/app/demos/tool-rendering/page.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering-default-catchall
name: Tool Rendering (Default Catch-all)
description: Out-of-the-box tool rendering — backend defines the tools; the
frontend adds zero custom renderers and relies on CopilotKit's built-in
default UI
tags:
- generative-ui
route: /demos/tool-rendering-default-catchall
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/tool-rendering-default-catchall/page.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering-custom-catchall
name: Tool Rendering (Custom Catch-all)
description: Single branded wildcard renderer via useDefaultRenderTool — the
same app-designed card paints every tool call
tags:
- generative-ui
route: /demos/tool-rendering-custom-catchall
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/tool-rendering-custom-catchall/page.tsx
- src/app/demos/tool-rendering-custom-catchall/custom-catchall-renderer.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering-reasoning-chain
name: Tool Rendering + Reasoning Chain
description: Sequential tool calls with reasoning tokens rendered side-by-side
tags:
- generative-ui
route: /demos/tool-rendering-reasoning-chain
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/tool-rendering-reasoning-chain/page.tsx
- src/app/api/copilotkit/route.ts
- id: gen-ui-agent
name: Agentic Generative UI
description: Long-running agent tasks with generated UI
tags:
- generative-ui
route: /demos/gen-ui-agent
animated_preview_url:
- id: gen-ui-tool-based
name: Tool-Based Generative UI
description: Agent uses tools to trigger UI generation
tags:
- generative-ui
route: /demos/gen-ui-tool-based
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/gen-ui-tool-based/page.tsx
- src/app/api/copilotkit/route.ts
- id: declarative-gen-ui
name: Declarative Generative UI (A2UI)
description: Dynamic A2UI BYOC via a custom catalog
tags:
- generative-ui
route: /demos/declarative-gen-ui
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/declarative-gen-ui/page.tsx
- src/app/demos/declarative-gen-ui/a2ui/catalog.ts
- src/app/api/copilotkit-declarative-gen-ui/route.ts
- id: a2ui-fixed-schema
name: Declarative Generative UI (A2UI — Fixed Schema)
description: A2UI rendering against a known client-side schema
tags:
- generative-ui
route: /demos/a2ui-fixed-schema
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/a2ui-fixed-schema/page.tsx
- src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts
- src/app/demos/a2ui-fixed-schema/a2ui/definitions.ts
- src/app/demos/a2ui-fixed-schema/a2ui/renderers.tsx
- src/app/api/copilotkit-a2ui-fixed-schema/route.ts
- id: a2ui-recovery
name: A2UI Error Recovery
description: Makes the A2UI validate->retry recovery loop visible — an invalid first render heals to a valid one, and an always-invalid render shows a graceful recovery-exhausted fallback. The Strands adapter runs the recovery loop on its auto-inject path; reuses the declarative-gen-ui catalog.
tags:
- generative-ui
route: /demos/a2ui-recovery
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/a2ui-recovery/page.tsx
- src/app/demos/a2ui-recovery/suggestions.ts
- src/app/api/copilotkit-a2ui-recovery/route.ts
- id: shared-state-read-write
name: Shared State (Read + Write)
description: Bidirectional agent state — UI writes preferences, agent writes notes back
tags:
- agent-state
route: /demos/shared-state-read-write
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/shared-state-read-write/page.tsx
- src/app/demos/shared-state-read-write/preferences-card.tsx
- src/app/demos/shared-state-read-write/notes-card.tsx
- src/app/api/copilotkit/route.ts
- id: shared-state-read
name: "Shared State: Read-only"
description: "Recipe editor publishes form state via agent.setState; the agent reads the recipe context but does not mutate it (no backend tool — neutral default agent)."
tags:
- agent-state
route: /demos/shared-state-read
animated_preview_url:
highlight:
- src/app/demos/shared-state-read/page.tsx
- src/app/api/copilotkit/route.ts
- id: subagents
name: Sub-Agents
description: Multiple agents with visible task delegation
tags:
- multi-agent
route: /demos/subagents
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/subagents/page.tsx
- src/app/demos/subagents/delegation-log.tsx
- src/app/api/copilotkit/route.ts
- id: shared-state-streaming
name: State Streaming
description: Per-token state delta streaming from agent to UI
tags:
- agent-state
route: /demos/shared-state-streaming
animated_preview_url:
- id: readonly-state-agent-context
name: Readonly State (Agent Context)
description: Frontend provides read-only context to the agent via useAgentContext
tags:
- agent-state
route: /demos/readonly-state-agent-context
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/readonly-state-agent-context/page.tsx
- src/app/api/copilotkit/route.ts
- id: multimodal
name: Multimodal Attachments
description: Image and PDF uploads via CopilotChat attachments
tags:
- chat-ui
route: /demos/multimodal
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/multimodal/page.tsx
- src/app/demos/multimodal/sample-attachment-buttons.tsx
- src/app/api/copilotkit-multimodal/route.ts
- id: voice
name: Voice Input
description: Speech-to-text via @copilotkit/voice with a bundled sample audio button
tags:
- chat-ui
route: /demos/voice
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/voice/page.tsx
- src/app/demos/voice/sample-audio-button.tsx
- src/app/api/copilotkit-voice/[[...slug]]/route.ts
- id: auth
name: Authentication
description: Bearer-token gate via runtime onRequest hook
tags:
- chat-ui
route: /demos/auth
animated_preview_url:
highlight:
- src/app/demos/auth/page.tsx
- src/app/demos/auth/auth-banner.tsx
- src/app/demos/auth/use-demo-auth.ts
- src/app/demos/auth/demo-token.ts
- src/app/api/copilotkit-auth/[[...slug]]/route.ts
- id: agent-config
name: Agent Config Object
description:
Forward a typed config object (tone / expertise / response length)
from provider to agent
tags:
- platform
route: /demos/agent-config
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/agent-config/page.tsx
- src/app/demos/agent-config/config-card.tsx
- src/app/demos/agent-config/use-agent-config.ts
- src/app/demos/agent-config/config-types.ts
- src/app/api/copilotkit-agent-config/route.ts
- id: declarative-hashbrown
name: "Declarative UI: Hashbrown"
description: Streaming structured output via @hashbrownai/react, rendering a sales dashboard catalog (MetricCard + PieChart + BarChart)
tags:
- generative-ui
route: /demos/declarative-hashbrown
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/declarative-hashbrown/page.tsx
- src/app/demos/declarative-hashbrown/hashbrown-renderer.tsx
- src/app/demos/declarative-hashbrown/metric-card.tsx
- src/app/demos/declarative-hashbrown/charts/bar-chart.tsx
- src/app/demos/declarative-hashbrown/charts/pie-chart.tsx
- src/app/demos/declarative-hashbrown/types.ts
- src/app/demos/declarative-hashbrown/suggestions.ts
- src/app/api/copilotkit-declarative-hashbrown/route.ts
- id: declarative-json-render
name: "Declarative UI: json-render"
description: Streaming hierarchical JSON UI spec rendered via @json-render/react, with a Zod-validated catalog (MetricCard + PieChart + BarChart)
tags:
- generative-ui
route: /demos/declarative-json-render
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/declarative-json-render/page.tsx
- src/app/demos/declarative-json-render/json-render-renderer.tsx
- src/app/demos/declarative-json-render/registry.tsx
- src/app/demos/declarative-json-render/catalog.ts
- src/app/demos/declarative-json-render/metric-card.tsx
- src/app/demos/declarative-json-render/charts/bar-chart.tsx
- src/app/demos/declarative-json-render/charts/pie-chart.tsx
- src/app/demos/declarative-json-render/types.ts
- src/app/demos/declarative-json-render/suggestions.ts
- src/app/api/copilotkit-declarative-json-render/route.ts
- id: open-gen-ui
name: Open Generative UI (Minimal)
description: Agent-authored HTML/CSS/SVG in a sandboxed iframe, minimal variant
tags:
- generative-ui
route: /demos/open-gen-ui
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/open-gen-ui/page.tsx
- src/app/api/copilotkit-ogui/route.ts
- id: open-gen-ui-advanced
name: Open Generative UI (Advanced)
description:
Sandboxed iframe that invokes host-registered sandbox functions via
Websandbox.connection.remote
tags:
- generative-ui
route: /demos/open-gen-ui-advanced
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/open-gen-ui-advanced/page.tsx
- src/app/demos/open-gen-ui-advanced/sandbox-functions.ts
- src/app/api/copilotkit-ogui/route.ts
- id: mcp-apps
name: MCP Apps
description: MCP server-driven UI via activity renderers
tags:
- generative-ui
route: /demos/mcp-apps
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/mcp-apps/page.tsx
- src/app/api/copilotkit-mcp-apps/route.ts
- id: beautiful-chat
name: Beautiful Chat
description: Polished landing-style chat surface with brand theming and suggestion pills
tags:
- chat-ui
route: /demos/beautiful-chat
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/beautiful-chat/page.tsx
- src/app/api/copilotkit/route.ts
- id: gen-ui-interrupt
name: In-Chat HITL (useInterrupt — low-level primitive)
description: Interactive component rendered inline in the chat via the
lower-level `useInterrupt` primitive — direct control over the interrupt
lifecycle
tags:
- generative-ui
route: /demos/gen-ui-interrupt
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/gen-ui-interrupt/page.tsx
- src/app/demos/gen-ui-interrupt/_components/time-picker-card.tsx
- src/app/api/copilotkit/route.ts
- id: interrupt-headless
name: Headless Interrupt (testing)
description: Resolve interrupts from a plain button grid — no chat, no
useInterrupt render prop
tags:
- interactivity
route: /demos/interrupt-headless
animated_preview_url:
highlight:
- src/agent/agent.ts
- src/app/demos/interrupt-headless/page.tsx
- src/app/api/copilotkit/route.ts
@@ -0,0 +1,25 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Allow iframe embedding from the showcase shell
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Frame-Options",
value: "ALLOWALL",
},
{
key: "Content-Security-Policy",
value: "frame-ancestors *;",
},
],
},
];
},
typescript: { ignoreBuildErrors: true },
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
{
"name": "@copilotkit/showcase-strands-typescript",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"next dev --turbopack\" \"cd src/agent && npm run dev\"",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test:e2e": "playwright test"
},
"dependencies": {
"@ag-ui/client": "0.0.57",
"@copilotkit/a2ui-renderer": "1.61.2",
"@copilotkit/react-core": "1.61.2",
"@copilotkit/react-ui": "1.61.2",
"@copilotkit/runtime": "1.61.2",
"@copilotkit/shared": "1.61.2",
"@copilotkit/voice": "1.61.2",
"@hashbrownai/core": "0.5.0-beta.4",
"@hashbrownai/react": "0.5.0-beta.4",
"@json-render/core": "0.18.0",
"@json-render/react": "0.18.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-separator": "^1.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^1.14.0",
"next": "^15.5.15",
"openai": "5.9.0",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"recharts": "^2.15.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"yaml": "^2.8.4",
"zod": "^3.24.0"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"concurrently": "^9.1.0",
"postcss": "^8.5.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
},
"overrides": {
"@copilotkit/web-inspector": {
"@copilotkit/core": "1.61.2"
}
},
"pnpm": {
"overrides": {
"@copilotkit/web-inspector>@copilotkit/core": "1.61.2"
}
}
}
@@ -0,0 +1,35 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
trace: "on-first-retry",
extraHTTPHeaders: {
"X-AIMock-Context": "strands-typescript",
},
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: process.env.CI
? undefined
: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: true,
env: {
...process.env,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL || "",
OPENAI_API_KEY: process.env.OPENAI_API_KEY || "",
},
},
});
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -0,0 +1,18 @@
# Voice demo audio
Drop a small (<100KB) WAV file named `sample.wav` in this directory. The voice
demo (`/demos/voice`) fetches it client-side and POSTs to the transcription
endpoint so the flow works without mic permissions.
Expected content: an audio clip speaking the phrase
**"What is the weather in Tokyo?"** — the demo caption advertises that phrase
to the user, and the bundled QA checklist + E2E spec assert the transcribed
text contains "weather" and/or "Tokyo".
Generate locally, for example:
- macOS: `say -o sample.aiff "What is the weather in Tokyo?" && ffmpeg -i sample.aiff -ar 16000 -ac 1 sample.wav`
- Linux: `espeak-ng -w sample.wav "What is the weather in Tokyo?"`
- Windows: PowerShell `System.Speech.Synthesis.SpeechSynthesizer``SetOutputToWaveFile`
Target: 16kHz mono, 3-5s duration, <100KB.
@@ -0,0 +1,22 @@
# Demo Files — Multimodal Demo
This directory bundles sample files referenced by the `/demos/multimodal` page.
Required files (must be committed as binaries; see `.gitattributes` at repo root):
- `sample.png` — a small (< 50 KB) PNG that the "Try with sample image" button
injects into the chat. A CopilotKit logo or other recognizable brand mark
works well so the vision-capable agent has something to describe.
- `sample.pdf` — a small (< 50 KB) one-page PDF that the "Try with sample PDF"
button injects. The content must mention "CopilotKit" so E2E soft
assertions hold (e.g. a one-page export of the CopilotKit quickstart).
The page at `src/app/demos/multimodal/page.tsx` fetches these via the public
path (`/demo-files/sample.png`, `/demo-files/sample.pdf`), wraps the fetched
blob in a `File`, and routes it through the same `AttachmentsConfig.onUpload`
callback the paperclip button uses — so the sample path exercises the exact
same queueing code as a real user upload.
If these files are missing, the demo page still renders but the sample
buttons will surface a fetch error. The paperclip / drag-and-drop paths
continue to work without them.
@@ -0,0 +1,80 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 9 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
>>
endobj
7 0 obj
<<
/Author (CopilotKit) /CreationDate (D:20260424104452+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260424104452+02'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (CopilotKit Quickstart) /Trapped /False
>>
endobj
8 0 obj
<<
/Count 1 /Kids [ 5 0 R ] /Type /Pages
>>
endobj
9 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 942
>>
stream
Gat=)gMRoa&:N^l7S$NN`GHCmThV;Z2^rJ`P):Y@]XYQU3:,AeX>cqAr-QI2/=2N"LfPX,loA_(*1m\5btm94UD2YX&6drn#XTAg+m:hr0`277L6Xugi+Zi-%K4(Zk-_X)UIg0#"[s_$PB)p"JhpIS$sekH(N!]Wm13pi-C<+2Z\%NM%.@X"c6;!4Z\-]lQ_FW]'b$98<b+A;AK8VL'gsRZ%'52u3[a]V,-i>M8Rs*Je-b?d:1;&?$,`ad5V$Xf0gWUq#g$2OoROl&VT9iG>Z7e:(6,L^5qWRe9f&aq&5@k4gFJA#"jE(QdPamJY0Hpqn>r_e:*FVtpYPMT?RZaJ8P7Ca0<':`;qM'##`XXPXO9RF6i%iKqn<L:oPEI$hcphT25S/nN3<DAK4NVdiJL>7#+eNHaXE@Y[_>W6oJG]uZn6;#DFBr_^iD&OGjVg%"ij8a:&7/qaE+7a=d``5hdQO":;.r]$4@TOAQt"^6*,kR%u>XHZrCjf@5l+)<U?'C_6MX"fJq<J@]>%B@BumKlTA+:A9AG5jqSXcFh[j7G)iN0D\N/+I[;hq]7D]ZAE\d8UW,N3+.Br<N@#$kD%W'f-CmKrYTKkeeuX8:pui+(3#6dK@@XC>.sl!SW4(X]/!H@1dH0Qp(55E^KJk1]UY=9;_"eb*-cL!Qq,hJ?,?l*JeVfts!/i_[Ejo:h;fDQSkbMmd[X?rTSO%M5Qeh<MleEs/@&O$fl?bGg9%rX0LW>9/:K"H3?p7"G:0Ha3Qj[6jL<;9nL312;])jCZe6\\@E*Ttr?Xg2lNp?68b2o5,cF;--/kSU`mMnHk40JE<",2f4YKj@2GX^Q[V>`.2@i]-rEiq2ARgR#W*>Hsb'riEWK24?;X\G?nL'id4l2fR3ocbEr3QJOgLr..O8N<DDWc>P*6r.',3Q1&LkGF@<;hrL[kDNqL~>endstream
endobj
xref
0 10
0000000000 65535 f
0000000061 00000 n
0000000112 00000 n
0000000219 00000 n
0000000331 00000 n
0000000436 00000 n
0000000629 00000 n
0000000697 00000 n
0000000982 00000 n
0000001041 00000 n
trailer
<<
/ID
[<ad0be0ae447f61082137085c690fb230><ad0be0ae447f61082137085c690fb230>]
% ReportLab generated PDF document -- digest (opensource)
/Info 7 0 R
/Root 6 0 R
/Size 10
>>
startxref
2073
%%EOF
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

@@ -0,0 +1,24 @@
# QA — a2ui-fixed-schema
## Scope
Manual QA checklist for the `a2ui-fixed-schema` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/a2ui-fixed-schema`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `a2ui-fixed-schema` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,44 @@
# QA: A2UI Error Recovery — AWS Strands (TypeScript)
## Prerequisites
- Demo is deployed and accessible at `/demos/a2ui-recovery` on the dashboard host
- Agent backend is healthy; `OPENAI_API_KEY` is set; `AGENT_URL` points at the Strands (TS) agent server; the recovery agent is mounted at `AGENT_URL/a2ui-recovery/` (registered as agent name `a2ui-recovery` — see `src/app/api/copilotkit-a2ui-recovery/route.ts` and `src/agent/server.ts`)
- Requires `@ag-ui/aws-strands` with A2UI recovery (the validate→retry loop + `a2ui_recovery_exhausted` hard-fail envelope run on the adapter's auto-inject path) and the `@copilotkit` A2UI renderer (the `building`/`retrying`/`failed` lifecycle rendering)
- Wiring: the page's provider catalog auto-enables A2UI tool injection; the Strands adapter auto-injects `generate_a2ui`, drives the `render_a2ui` planner, and runs the recovery loop itself (no explicit backend tool, unlike the langgraph/ADK siblings — see `buildA2uiRecoveryAgent` in `src/agent/agent.ts`)
- Reuses the **declarative-gen-ui** catalog (`catalogId: "declarative-gen-ui-catalog"`) and the Vantage Threads sales context — no new components
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/a2ui-recovery`; verify the page renders within 3s and a single `CopilotChat` pane is centered (max-width ~896px, rounded-2xl, full-height)
- [ ] Verify the chat is wired to `runtimeUrl="/api/copilotkit-a2ui-recovery"` and `agent="a2ui-recovery"` (DevTools → Network: sending a message hits that endpoint, not `/api/copilotkit`)
- [ ] Verify both suggestion pills are visible with verbatim titles:
- "Recover a bad render"
- "Show an unrecoverable failure"
### 2. Healing path
- [ ] Click "Recover a bad render" ("Render my Q2 sales dashboard, recovering if the first attempt is malformed.")
- [ ] The inner `render_a2ui` returns **free-form / sloppy** A2UI args (components & data as JSON strings rather than structured arrays). Verify the middleware **heals** them via `parse_and_fix` into a valid surface that paints (no broken surface, no error banner)
- [ ] Verify the **painted** surface is valid: a `declarative-metric` row ("Quarterly Revenue $4.2M", "Win Rate 31%")
- [ ] DevTools → Network: verify the final tool result carries an `a2ui_operations` container (no `a2ui_recovery_exhausted`)
- [ ] Verify the chat reply is one short sentence noting the heal
### 3. Hard-fail (recovery exhausted) path
- [ ] Click "Show an unrecoverable failure" ("Render a dashboard that keeps failing validation so I can see the fallback.")
- [ ] Verify the lifecycle ends in a tasteful `failed` state (NOT a broken/half-rendered surface and NOT a silent drop)
- [ ] DevTools → Network: verify `render_a2ui` was attempted up to the cap (3 attempts, all invalid) and the tool returned an `a2ui_recovery_exhausted` envelope (no `a2ui_operations` painted)
- [ ] Verify the chat reply gracefully explains the fallback (one short sentence)
### 4. Regression / isolation
- [ ] Verify the recovery demo does not affect the declarative-gen-ui or beautiful-chat demos (separate routes/agents)
- [ ] Re-run each pill a second time and verify the same lifecycle
## Notes
- The malformed renders are forced by aimock fixtures (`showcase/aimock/d6/strands-typescript/a2ui-recovery.json`): the inner `render_a2ui` call is matched by `userMessage` + `toolName=render_a2ui`. Healing itself is performed live by the toolkit recovery loop inside the Strands adapter.
- AWS-Strands-TypeScript sibling of the langgraph-python `a2ui-recovery` demo. On Strands the recovery loop runs on the adapter's auto-inject path, so no explicit `getA2UITools` wiring is needed.
@@ -0,0 +1,24 @@
# QA — agent-config
## Scope
Manual QA checklist for the `agent-config` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/agent-config`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `agent-config` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — agentic-chat-reasoning
## Scope
Manual QA checklist for the `agentic-chat-reasoning` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/agentic-chat-reasoning`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `agentic-chat-reasoning` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,62 @@
# QA: Agentic Chat — AWS Strands
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the agentic-chat demo page
- [ ] Verify the chat interface loads with a text input placeholder "Type a message"
- [ ] Verify the background container (`data-testid="background-container"`) is visible
- [ ] Verify the default background color is the theme default (rgb(250, 250, 249))
- [ ] Send a basic message (e.g. "Hello")
- [ ] Verify the agent responds with a text message
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Change background" suggestion button is visible
- [ ] Verify "Generate sonnet" suggestion button is visible
- [ ] Click the "Change background" suggestion
- [ ] Verify the suggestion either populates the input or sends the message
#### Background Change (useFrontendTool)
- [ ] Ask "Change the background to a sunset gradient"
- [ ] Verify the background container style changes from the default
- [ ] Verify the change_background tool returns a success status
#### Weather Render Tool (useRenderTool)
- [ ] Type "What's the weather in Tokyo?"
- [ ] Verify loading state shows "Loading weather..." (`data-testid="weather-info-loading"`)
- [ ] Verify WeatherCard renders (`data-testid="weather-info"`) with:
- [ ] City name displayed
- [ ] Temperature in degrees C
- [ ] Humidity percentage
- [ ] Wind speed in mph
- [ ] Conditions text
#### Agent Context
- [ ] Verify the agent knows the user's name is "Bob" (provided via useAgentContext)
- [ ] Ask "What is my name?" and verify the agent responds with "Bob"
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
- [ ] Send a very long message and verify no UI breakage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- Background changes are instant after tool execution
- Weather card renders with all data fields populated
- No UI errors or broken layouts
@@ -0,0 +1,24 @@
# QA — auth
## Scope
Manual QA checklist for the `auth` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/auth`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `auth` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,36 @@
# QA — byoc-hashbrown
## Scope
Manual QA checklist for the `byoc-hashbrown` demo in the AWS Strands
showcase. The agent emits a hashbrown JSON envelope that `@hashbrownai/react`
progressively parses and renders via the MetricCard / PieChart / BarChart /
DealCard / Markdown catalog.
## Happy path
- [ ] Navigate to `/demos/byoc-hashbrown`.
- [ ] Page renders with header "BYOC: Hashbrown" and no console errors.
- [ ] Suggestion pills appear in the composer (Sales dashboard, Revenue by
category, Expense trend).
- [ ] Click "Sales dashboard" — the assistant replies with a progressively
streaming dashboard that includes at least one metric card, one pie
chart, and one bar chart.
- [ ] Click "Revenue by category" — the assistant replies with a pie chart
with 4+ segments.
- [ ] Click "Expense trend" — the assistant replies with a bar chart.
## Regression
- [ ] The assistant's response is rendered as a visual dashboard (NOT as
raw JSON in a chat bubble).
- [ ] `data-testid="metric-card"`, `data-testid="pie-chart"`, and
`data-testid="bar-chart"` are present on their respective elements.
- [ ] No hydration warnings.
## Known gaps
- The Strands backend uses the shared agent from `agent.py`; the
hashbrown JSON envelope prompt is injected via `useAgentContext` on the
frontend. The canonical prompt lives in `src/agents/byoc_hashbrown.py`
as documentation.
@@ -0,0 +1,33 @@
# QA — byoc-json-render
## Scope
Manual QA checklist for the `byoc-json-render` demo. The agent emits a
`{ root, elements }` JSON spec that `@json-render/react`'s `<Renderer>`
mounts against a Zod-validated catalog.
## Happy path
- [ ] Navigate to `/demos/byoc-json-render`.
- [ ] Page renders, chat composer shows suggestion pills.
- [ ] Click "Sales dashboard" — the assistant replies with a rendered
dashboard (MetricCard + BarChart) rather than raw JSON.
- [ ] Click "Revenue by category" — the assistant replies with a PieChart
with 4 segments.
- [ ] Click "Expense trend" — the assistant replies with a BarChart.
## Regression
- [ ] `data-testid="json-render-root"` is present on the rendered
assistant message wrapper.
- [ ] `data-testid="metric-card"` appears when a MetricCard is the root.
- [ ] Nested children of MetricCard (e.g. the BarChart in the Sales
Dashboard worked example) render — they are NOT silently dropped
(PR #4271 fix).
- [ ] No "useVisibility must be used within a VisibilityProvider" crash
(PR #4271 fix — `<JSONUIProvider>` wraps `<Renderer>`).
## Known gaps
- Uses the shared Strands backend via the prompt injected on the frontend;
canonical prompt lives in `src/agents/byoc_json_render.py`.
@@ -0,0 +1,24 @@
# QA — chat-customization-css
## Scope
Manual QA checklist for the `chat-customization-css` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/chat-customization-css`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `chat-customization-css` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — chat-slots
## Scope
Manual QA checklist for the `chat-slots` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/chat-slots`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `chat-slots` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — declarative-gen-ui
## Scope
Manual QA checklist for the `declarative-gen-ui` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/declarative-gen-ui`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `declarative-gen-ui` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — frontend-tools-async
## Scope
Manual QA checklist for the `frontend-tools-async` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/frontend-tools-async`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `frontend-tools-async` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — frontend-tools
## Scope
Manual QA checklist for the `frontend-tools` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/frontend-tools`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `frontend-tools` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,62 @@
# QA: Agentic Generative UI — AWS Strands
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the gen-ui-agent demo page
- [ ] Verify the chat interface loads in a centered full-height layout
- [ ] Verify the chat input placeholder "Type a message" is visible
- [ ] Send a basic message
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Simple plan" suggestion button is visible (plan to go to Mars in 5 steps)
- [ ] Verify "Complex plan" suggestion button is visible (plan to make pizza in 10 steps)
#### Task Progress Tracker (useAgent with state streaming)
- [ ] Click "Simple plan" suggestion or type "Build a plan to go to Mars in 5 steps"
- [ ] Verify the TaskProgress component renders (`data-testid="task-progress"`)
- [ ] Verify the progress bar appears with a gradient fill
- [ ] Verify step items appear with descriptions (`data-testid="task-step-text"`)
- [ ] Verify the "N/N Complete" counter updates as steps complete
- [ ] Verify completed steps show:
- Green background gradient
- Check icon
- Green text color
- [ ] Verify the current pending step shows:
- Blue/purple background gradient
- Spinner icon with "Processing..." text
- Pulsing animation
- [ ] Verify future pending steps show:
- Gray background
- Clock icon
- Muted text color
#### Complex Plan
- [ ] Type "Plan to make pizza in 10 steps"
- [ ] Verify 10 steps appear in the progress tracker
- [ ] Verify progress bar width increases as steps complete
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- Task progress tracker shows live step completion
- Progress bar animates smoothly
- No UI errors or broken layouts
@@ -0,0 +1,59 @@
# QA: Tool-Based Generative UI — AWS Strands
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the gen-ui-tool-based demo page
- [ ] Verify the CopilotSidebar opens by default with title "Haiku Generator"
- [ ] Verify a placeholder haiku card is displayed on the main area
- [ ] Send a basic message via the sidebar
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Nature Haiku" suggestion button is visible
- [ ] Verify "Ocean Haiku" suggestion button is visible
- [ ] Verify "Spring Haiku" suggestion button is visible
#### Haiku Generation (useFrontendTool)
- [ ] Click the "Nature Haiku" suggestion or type "Write me a haiku about nature"
- [ ] Verify a HaikuCard renders (`data-testid="haiku-card"`) with:
- [ ] Three Japanese text lines (`data-testid="haiku-japanese-line"`)
- [ ] Three English translation lines (`data-testid="haiku-english-line"`)
- [ ] A background gradient style applied to the card
- [ ] Verify the Japanese text contains actual Japanese characters (not Latin)
- [ ] Verify the English lines are a readable English translation
#### Image Display
- [ ] After haiku generation, verify an image renders (`data-testid="haiku-image"`) if the agent provides an image_name
- [ ] Verify the image src points to /images/ with a valid filename from the predefined list
#### Multiple Haikus
- [ ] Generate a second haiku (e.g. "Ocean Haiku")
- [ ] Verify the new haiku card appears at the top
- [ ] Verify the previous haiku card is still visible below
- [ ] Verify the initial placeholder haiku is removed
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Sidebar loads within 3 seconds
- Agent responds and generates haiku within 10 seconds
- Haiku cards display with Japanese and English text
- Generated haikus stack with newest on top
- No UI errors or broken layouts
@@ -0,0 +1,24 @@
# QA — headless-complete
## Scope
Manual QA checklist for the `headless-complete` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/headless-complete`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `headless-complete` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — headless-simple
## Scope
Manual QA checklist for the `headless-simple` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/headless-simple`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `headless-simple` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — hitl-in-app
## Scope
Manual QA checklist for the `hitl-in-app` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/hitl-in-app`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `hitl-in-app` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,57 @@
# QA: Human in the Loop — AWS Strands
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the HITL demo page (`/demos/hitl`) > _Note: the URL path `/demos/hitl` is intentionally shorter than the feature id `hitl-in-chat`._
- [ ] Verify the chat interface loads in a centered max-w-4xl container
- [ ] Verify the chat input placeholder "Type a message" is visible
- [ ] Send a basic message
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Simple plan" suggestion button is visible
- [ ] Verify "Complex plan" suggestion button is visible
- [ ] Click the "Simple plan" suggestion
- [ ] Verify it triggers a message about planning a trip to Mars in 5 steps
#### Step Selection and Approval
The HITL demo renders a single StepSelector card regardless of whether
the underlying flow uses an interrupt hook or a frontend HITL tool. The
framework-specific hook name is an implementation detail covered in each
package's demo source; QA below validates the user-visible card behavior.
- [ ] Send "Plan a trip to Mars in 5 steps"
- [ ] Verify the StepSelector card appears (`data-testid="select-steps"`)
- [ ] Verify step items are displayed with checkboxes (`data-testid="step-item"`)
- [ ] Verify step text is visible (`data-testid="step-text"`)
- [ ] Verify the selected count display shows "N/N selected"
- [ ] Toggle a step checkbox off and verify the count decreases
- [ ] Toggle it back on and verify the count increases
- [ ] Click "Perform Steps (N)" / "Confirm (N)" button
- [ ] Verify the agent continues processing after confirmation
- [ ] In a new conversation, trigger the same flow and click "Reject" (where present)
- [ ] Verify the card reflects the decision (Accepted / Rejected) and buttons disable
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- Step selector renders with toggleable checkboxes
- Accept/Reject flow completes without errors
- No UI errors or broken layouts
@@ -0,0 +1,24 @@
# QA — multimodal
## Scope
Manual QA checklist for the `multimodal` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/multimodal`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `multimodal` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,28 @@
# QA — open-gen-ui-advanced
## Scope
Manual QA for the advanced Open Generative UI demo. Builds on open-gen-ui
by adding `openGenerativeUI.sandboxFunctions` (evaluateExpression,
notifyHost) that the agent-authored iframe can invoke via
`Websandbox.connection.remote.<name>(...)`.
## Happy path
- [ ] Navigate to `/demos/open-gen-ui-advanced`.
- [ ] Composer shows three sandbox-function suggestion pills.
- [ ] Click "Calculator (calls evaluateExpression)" — a calculator mounts
in a sandboxed iframe. Press a few digits and `=` — the display
shows the evaluated result.
- [ ] Click "Ping the host (calls notifyHost)" — a card mounts with a
"Say hi to the host" button. Clicking it shows a host-returned
confirmation with a `receivedAt` timestamp.
## Regression
- [ ] Check the browser console for `[open-gen-ui/advanced]` log lines
proving the sandbox -> host round trip fired.
## Known gaps
- Same Strands backend as the minimal variant.
@@ -0,0 +1,29 @@
# QA — open-gen-ui
## Scope
Manual QA for the minimal Open Generative UI demo. The agent emits a
`generateSandboxedUi` tool call on every turn; the runtime's
OpenGenerativeUIMiddleware converts that into activity events that mount
the authored HTML/CSS in a sandboxed iframe.
## Happy path
- [ ] Navigate to `/demos/open-gen-ui`.
- [ ] Composer shows the four visualization suggestion pills.
- [ ] Click "3D axis visualization (model airplane)" — a sandboxed iframe
mounts showing an airplane cycling through pitch/yaw/roll rotations.
- [ ] Click "How a neural network works" — an iframe mounts showing a
layered network with forward-pass activations.
## Regression
- [ ] The rendered scenes include axis labels, a legend, and a title.
- [ ] No console errors from the sandboxed iframe.
## Known gaps
- Strands backend uses the shared agent; the `generateSandboxedUi`
frontend-registered tool is resolved via ag_ui_strands' frontend-tool
proxy. The design skill prompt lives on the frontend via
`openGenerativeUI.designSkill`.
@@ -0,0 +1,24 @@
# QA — prebuilt-popup
## Scope
Manual QA checklist for the `prebuilt-popup` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/prebuilt-popup`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `prebuilt-popup` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — prebuilt-sidebar
## Scope
Manual QA checklist for the `prebuilt-sidebar` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/prebuilt-sidebar`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `prebuilt-sidebar` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — readonly-state-agent-context
## Scope
Manual QA checklist for the `readonly-state-agent-context` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/readonly-state-agent-context`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `readonly-state-agent-context` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — reasoning-default-render
## Scope
Manual QA checklist for the `reasoning-default-render` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/reasoning-default-render`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `reasoning-default-render` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,61 @@
# QA: Shared State (Read + Write) — AWS Strands
## Prerequisites
- Demo is deployed and accessible at `/demos/shared-state-read-write` on the dashboard host
- Agent backend is healthy (`/api/health`); `OPENAI_API_KEY` is set on Railway; the Strands agent server (`agent_server.py`) is reachable at `AGENT_URL`
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/shared-state-read-write`; verify the page renders within 3s with the left sidebar (preferences + notes cards) and the right-side `CopilotChat` pane
- [ ] Verify `data-testid="preferences-card"` is visible with heading "Your preferences"
- [ ] Verify `data-testid="notes-card"` is visible with heading "Agent notes" and empty-state `data-testid="notes-empty"` reading "No notes yet. Ask the agent to remember something."
- [ ] Verify the chat input placeholder is "Chat with the agent..."
- [ ] Verify all 3 suggestion pills are visible with verbatim titles: "Greet me", "Remember something", "Plan a weekend"
- [ ] Send "Hello" and verify an assistant text response appears within 10s
### 2. Feature-Specific Checks
#### UI Writes -> Agent Reads (preferences via `agent.setState`)
- [ ] Type "Atai" into `data-testid="pref-name"`; verify `data-testid="pref-state-json"` updates to include `"name": "Atai"`
- [ ] Change `data-testid="pref-tone"` to `formal`; verify the JSON preview reflects `"tone": "formal"`
- [ ] Change `data-testid="pref-language"` to `Spanish`; verify the JSON preview reflects `"language": "Spanish"`
- [ ] Click the `Cooking` and `Travel` interest pills; verify both show the selected style (border `#BEC2FF`, bg `#BEC2FF1A`) and the JSON preview's `interests` array contains both entries
- [ ] Send "What do you know about me?"; verify within 10s the assistant reply references the name "Atai", a formal tone, Spanish, and the Cooking/Travel interests (the `build_state_prompt` Strands `state_context_builder` injects these into the user message each turn)
- [ ] Click the "Plan a weekend" suggestion; verify the reply is tailored to the selected interests
#### Agent Writes -> UI Reads (notes via `set_notes` tool)
- [ ] Click the "Remember something" suggestion (sends "Remember that I prefer morning meetings and that I don't eat dairy.")
- [ ] Within 15s verify `data-testid="notes-list"` appears in the notes card and contains at least 2 `data-testid="note-item"` entries mentioning "morning meetings" and "dairy"
- [ ] Verify `data-testid="notes-empty"` is no longer rendered
- [ ] Send "Also remember I live in Berlin."; verify within 15s the notes list grows (previous notes preserved, new note added) — confirms the agent passes the FULL updated list per `set_notes` contract and the `state_from_args` hook publishes a full snapshot
#### UI Writes Back to Agent-Authored Slice (clear notes)
- [ ] With notes present, verify `data-testid="notes-clear-button"` is visible
- [ ] Click the Clear button; verify the notes list disappears and `data-testid="notes-empty"` re-renders
- [ ] Ask "What do you remember about me?"; verify the agent no longer cites the cleared notes (state was written back by the UI via `agent.setState({ notes: [] })`)
#### Multi-Turn State Persistence
- [ ] Change tone to `playful` and add the `Music` interest; send "Write me a one-line haiku greeting."; verify the reply is playful and references music
- [ ] Send a follow-up "Do it again in French."; verify the reply remains playful, switches to French, and still acknowledges the music interest — confirms preferences persist across turns without being re-sent
- [ ] Reload the page; verify preferences reset to defaults (`tone: casual`, `language: English`, empty interests, empty name) and notes reset to empty (state is per-session, seeded by the page's `useEffect`)
### 3. Error Handling
- [ ] Attempt to send an empty message; verify it is a no-op (no user bubble, no assistant response)
- [ ] Deselect all interests and clear the name; send "Who am I?"; verify the agent answers without crashing (`build_state_prompt` skips injection when `preferences` is empty)
- [ ] Verify DevTools -> Console shows no uncaught errors during any flow above
## Expected Results
- Page loads within 3 seconds; assistant text response within 10 seconds
- Preferences writes are reflected in `pref-state-json` synchronously on change
- Agent-authored notes appear in `notes-card` within 15 seconds of a "remember" prompt, and the full prior list is preserved on subsequent `set_notes` calls (the `notes_state_from_args` hook returns the FULL updated list as a `StateSnapshotEvent`)
- Clear button round-trips UI -> agent state and the agent loses access to the cleared notes on the next turn
- No UI layout breaks, no uncaught console errors
@@ -0,0 +1,75 @@
# QA: Shared State (Reading) — AWS Strands
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the shared-state-read demo page
- [ ] Verify the recipe card form loads (`data-testid="recipe-card"`)
- [ ] Verify the CopilotSidebar opens by default with title "AI Recipe Assistant"
- [ ] Send a message via the sidebar
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Initial Recipe State
- [ ] Verify the recipe title input shows "Make Your Recipe"
- [ ] Verify the cooking time dropdown defaults to "45 min"
- [ ] Verify the skill level dropdown defaults to "Intermediate"
- [ ] Verify the default ingredients are displayed:
- [ ] Carrots (3 large, grated) with carrot emoji
- [ ] All-Purpose Flour (2 cups) with wheat emoji
- [ ] Verify the default instruction is displayed: "Preheat oven to 350 F"
#### Suggestions
- [ ] Verify "Create Italian recipe" suggestion is visible
- [ ] Verify "Make it healthier" suggestion is visible
- [ ] Verify "Suggest variations" suggestion is visible
#### Recipe Editing (Local State)
- [ ] Edit the recipe title and verify it updates
- [ ] Change the skill level dropdown and verify it updates
- [ ] Change the cooking time dropdown and verify it updates
- [ ] Toggle a dietary preference checkbox (e.g. "Vegetarian") and verify it's checked
- [ ] Click "+ Add Ingredient" (`data-testid="add-ingredient-button"`) and verify a new empty row appears
- [ ] Edit an ingredient name and amount
- [ ] Remove an ingredient by clicking the "x" button
- [ ] Click "+ Add Step" and verify a new instruction row appears
- [ ] Edit an instruction and verify it saves
- [ ] Remove an instruction by clicking the "x" button
#### AI-Powered Recipe Updates (useAgent with shared state)
- [ ] Click "Create Italian recipe" suggestion
- [ ] Verify the agent updates the recipe title, ingredients, and instructions
- [ ] Verify the ping indicator appears on changed sections
- [ ] Verify the "Improve with AI" button (`data-testid="improve-button"`) changes to "Please Wait..." while loading
- [ ] Click "Improve with AI" and verify the recipe is enhanced
#### Agent Reads Frontend State
- [ ] Edit the recipe (change title, add ingredients)
- [ ] Ask the agent "What recipe am I making?"
- [ ] Verify the agent's response references the current recipe state
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
- [ ] Verify the "Improve with AI" button is disabled while loading
## Expected Results
- Recipe card and sidebar load within 3 seconds
- Agent responds within 10 seconds
- Recipe state syncs bidirectionally between UI and agent
- Ping indicators highlight changed sections
- No UI errors or broken layouts
@@ -0,0 +1,41 @@
# QA: State Streaming — AWS Strands
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the shared-state-streaming demo page
- [ ] Verify the chat interface loads with title "State Streaming"
- [ ] Verify the chat input placeholder "Type a message..." is visible
- [ ] Send a basic message (e.g. "Hello! What can you do?")
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Get started" suggestion button is visible
#### Note: Stub Demo
> **Status: Stub** — This demo is currently a stub (TODO: implement)
- [ ] Verify the basic CopilotChat loads and accepts messages
- [ ] Verify the agent responds to messages
- [ ] No custom UI components are expected beyond the chat interface
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- No UI errors or broken layouts
@@ -0,0 +1,41 @@
# QA: Shared State (Writing) — AWS Strands
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the shared-state-write demo page
- [ ] Verify the chat interface loads with title "Shared State (Writing)"
- [ ] Verify the chat input placeholder "Type a message..." is visible
- [ ] Send a basic message (e.g. "Hello! What can you do?")
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Get started" suggestion button is visible
#### Note: Stub Demo
> **Status: Stub** — This demo is currently a stub (TODO: implement)
- [ ] Verify the basic CopilotChat loads and accepts messages
- [ ] Verify the agent responds to messages
- [ ] No custom UI components are expected beyond the chat interface
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- No UI errors or broken layouts
@@ -0,0 +1,54 @@
# QA: Sub-Agents — AWS Strands
## Prerequisites
- Demo is deployed and accessible at `/demos/subagents` on the dashboard host
- Agent backend is healthy (`/api/health`); `OPENAI_API_KEY` is set; the Strands agent server (`agent_server.py`) is reachable at `AGENT_URL`
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/subagents`; verify the page renders within 3s with a wide delegation log on the left and a `CopilotChat` pane on the right
- [ ] Verify `data-testid="delegation-log"` is visible with heading "Sub-agent delegations"
- [ ] Verify the empty state reads "Ask the supervisor to complete a task. Every sub-agent it calls will appear here."
- [ ] Verify `data-testid="delegation-count"` reads "0 calls"
- [ ] Verify the chat input placeholder is "Give the supervisor a task..."
- [ ] Verify all 3 suggestion pills are visible: "Write a blog post", "Explain a topic", "Summarize a topic"
### 2. Feature-Specific Checks
#### Supervisor delegates to sub-agents (research → write → critique)
- [ ] Click the "Write a blog post" suggestion (sends a research/write/critique sequence prompt)
- [ ] Within 5s verify `data-testid="supervisor-running"` appears next to the heading with the pulsing indicator
- [ ] Within 60s verify `data-testid="delegation-count"` reads at least "3 calls" and at least 3 `data-testid="delegation-entry"` rows are rendered
- [ ] Verify the first entry has the `🔎 Research` badge, a non-empty `Task:` line, and a result body containing 3-5 bullet points
- [ ] Verify a subsequent entry has the `✍️ Writing` badge with a polished paragraph in the result body
- [ ] Verify a subsequent entry has the `🧐 Critique` badge with 2-3 actionable critiques
- [ ] Verify each entry shows status `completed` (green) once the sub-agent has returned
- [ ] Once the supervisor returns a final assistant text message, verify `supervisor-running` disappears
#### Live updates during the run
- [ ] Click the "Explain a topic" suggestion
- [ ] Verify entries appear ONE-AT-A-TIME (the count climbs from 0 → 1 → 2 → 3 over multiple seconds rather than all appearing at once) — this confirms each sub-agent's `state_from_result` hook emits an independent `StateSnapshotEvent`
- [ ] Verify entry numbers (`#1`, `#2`, `#3`) are sequential and stable
#### Multi-turn persistence
- [ ] After the first run completes, send "Now do the same for solar panels." — verify the delegation log GROWS (existing rows kept; new rows appended); the count should continue from where it left off (e.g. "3 calls" → "6 calls")
### 3. Error Handling
- [ ] Send an empty message — verify it is a no-op
- [ ] Send "Hello" (no task to delegate); verify the supervisor responds with a short text reply and NO new delegation entries are added
- [ ] Verify DevTools -> Console shows no uncaught errors during any flow above
## Expected Results
- Page loads within 3 seconds
- First delegation entry appears within 15s of submitting a task; full research → write → critique loop completes within 60s
- Each sub-agent invocation produces exactly one delegation entry with a non-empty `task` and `result`
- Supervisor's final summary references the work that was delegated
- No UI layout breaks, no uncaught console errors
@@ -0,0 +1,24 @@
# QA — tool-rendering-custom-catchall
## Scope
Manual QA checklist for the `tool-rendering-custom-catchall` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/tool-rendering-custom-catchall`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `tool-rendering-custom-catchall` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — tool-rendering-default-catchall
## Scope
Manual QA checklist for the `tool-rendering-default-catchall` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/tool-rendering-default-catchall`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `tool-rendering-default-catchall` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — tool-rendering-reasoning-chain
## Scope
Manual QA checklist for the `tool-rendering-reasoning-chain` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/tool-rendering-reasoning-chain`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `tool-rendering-reasoning-chain` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,61 @@
# QA: Tool Rendering — AWS Strands
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the tool-rendering demo page
- [ ] Verify the chat interface loads in a centered full-height layout
- [ ] Verify the chat input placeholder "Type a message" is visible
- [ ] Send a basic message
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Weather in San Francisco" suggestion button is visible
- [ ] Verify "Weather in New York" suggestion button is visible
- [ ] Verify "Weather in Tokyo" suggestion button is visible
- [ ] Click a weather suggestion and verify it populates the input or sends the message
#### Weather Card Rendering (useRenderTool)
- [ ] Type "What's the weather in San Francisco?"
- [ ] Verify loading state shows "Retrieving weather..." with a spinner
- [ ] Verify the WeatherCard renders (`data-testid="weather-card"`) with:
- [ ] City name displayed (`data-testid="weather-city"`)
- [ ] Temperature in both Celsius and Fahrenheit
- [ ] Humidity percentage (`data-testid="weather-humidity"`)
- [ ] Wind speed in mph (`data-testid="weather-wind"`)
- [ ] Feels-like temperature (`data-testid="weather-feels-like"`)
- [ ] Conditions text with appropriate weather icon (sun/rain/cloud)
- [ ] Verify the card background color matches the weather condition theme:
- Clear/Sunny: #667eea (blue-purple)
- Rain/Storm: #4A5568 (dark gray)
- Cloudy: #718096 (medium gray)
- Snow: #63B3ED (light blue)
#### Multiple Weather Queries
- [ ] Ask about weather in a second city
- [ ] Verify a second WeatherCard renders without breaking the first
- [ ] Verify each card shows the correct city name
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- Weather cards render with all data fields populated
- Weather icon and theme color match the conditions
- No UI errors or broken layouts
@@ -0,0 +1,24 @@
# QA — voice
## Scope
Manual QA checklist for the `voice` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/voice`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `voice` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,3 @@
node_modules/
dist/
*.log
@@ -0,0 +1,77 @@
[
{
"id": "root",
"component": "Card",
"child": "content"
},
{
"id": "content",
"component": "Column",
"children": ["title", "route", "meta", "bookButton"]
},
{
"id": "title",
"component": "Title",
"text": "Flight Details"
},
{
"id": "route",
"component": "Row",
"justify": "spaceBetween",
"align": "center",
"children": ["from", "arrow", "to"]
},
{
"id": "from",
"component": "Airport",
"code": { "path": "/origin" }
},
{
"id": "arrow",
"component": "Arrow"
},
{
"id": "to",
"component": "Airport",
"code": { "path": "/destination" }
},
{
"id": "meta",
"component": "Row",
"justify": "spaceBetween",
"align": "center",
"children": ["airline", "price"]
},
{
"id": "airline",
"component": "AirlineBadge",
"name": { "path": "/airline" }
},
{
"id": "price",
"component": "PriceTag",
"amount": { "path": "/price" }
},
{
"id": "bookButton",
"component": "Button",
"variant": "primary",
"child": "bookButtonLabel",
"action": {
"event": {
"name": "book_flight",
"context": {
"origin": { "path": "/origin" },
"destination": { "path": "/destination" },
"airline": { "path": "/airline" },
"price": { "path": "/price" }
}
}
}
},
{
"id": "bookButtonLabel",
"component": "Text",
"text": "Book flight"
}
]
@@ -0,0 +1,360 @@
/**
* Agent factories for the Strands TypeScript showcase backend.
*
* `buildShowcaseAgent` is the single shared agent that serves the vast
* majority of demos (the frontend differentiates each demo via
* useFrontendTool / useRenderTool / useHumanInTheLoop / useAgentContext).
* It mirrors the Python sibling's `build_showcase_agent` minus A2UI.
*
* The tool-free specialized agents (voice, byoc-hashbrown, byoc-json-render)
* are mounted on dedicated sub-paths by `server.ts`.
*/
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { Agent, tool } from "@strands-agents/sdk";
import { z } from "zod";
import { StrandsAgent } from "@ag-ui/aws-strands";
import type { StrandsAgentConfig } from "@ag-ui/aws-strands";
import {
A2UI_OPERATIONS_KEY,
createSurface,
updateComponents,
updateDataModel,
} from "@ag-ui/a2ui-toolkit";
import { createModel } from "./model-factory";
import { SHOWCASE_TOOLS } from "./tools";
import {
buildStatePrompt,
salesStateFromArgs,
notesStateFromArgs,
stepsStateFromArgs,
documentStateFromArgs,
makeSubagentStateFromResult,
} from "./state";
import {
SYSTEM_PROMPT,
VOICE_SYSTEM_PROMPT,
BYOC_HASHBROWN_SYSTEM_PROMPT,
BYOC_JSON_RENDER_SYSTEM_PROMPT,
} from "./prompts";
export async function buildShowcaseAgent(): Promise<StrandsAgent> {
const config: StrandsAgentConfig = {
stateContextBuilder: buildStatePrompt,
toolBehaviors: {
// Sales pipeline lives in shared state; emit the snapshot from args.
manage_sales_todos: {
skipMessagesSnapshot: true,
stateFromArgs: salesStateFromArgs,
},
// Shared State (Read + Write) — notes panel.
set_notes: { stateFromArgs: notesStateFromArgs },
// gen-ui-agent — live progress card driven by set_steps transitions.
set_steps: { stateFromArgs: stepsStateFromArgs },
// shared-state-streaming — stream the document string into state.
write_document: { stateFromArgs: documentStateFromArgs },
// Sub-agents — append a delegation entry carrying the actual output.
research_agent: {
stateFromResult: makeSubagentStateFromResult("research_agent"),
},
writing_agent: {
stateFromResult: makeSubagentStateFromResult("writing_agent"),
},
critique_agent: {
stateFromResult: makeSubagentStateFromResult("critique_agent"),
},
},
};
const strandsAgent = new Agent({
model: await createModel(),
systemPrompt: SYSTEM_PROMPT,
tools: SHOWCASE_TOOLS,
});
return new StrandsAgent({
agent: strandsAgent,
name: "strands_agent",
description:
"A polished CopilotKit demo assistant: chat, tools, shared state, HITL, sub-agents.",
config,
});
}
/** Tool-free agent for the voice demo (transcription + basic chat). */
export async function buildVoiceAgent(): Promise<StrandsAgent> {
const strandsAgent = new Agent({
model: await createModel(),
systemPrompt: VOICE_SYSTEM_PROMPT,
tools: [],
});
return new StrandsAgent({
agent: strandsAgent,
name: "voice_agent",
description: "Simple assistant for the voice demo — no tools.",
});
}
/** Tool-free hashbrown UI-kit envelope generator (declarative-hashbrown). */
export async function buildByocHashbrownAgent(): Promise<StrandsAgent> {
const strandsAgent = new Agent({
model: await createModel(),
systemPrompt: BYOC_HASHBROWN_SYSTEM_PROMPT,
tools: [],
});
return new StrandsAgent({
agent: strandsAgent,
name: "byoc_hashbrown",
description:
"Hashbrown UI-kit envelope generator for the declarative-hashbrown demo.",
});
}
/** Tool-free json-render flat-spec generator (declarative-json-render). */
export async function buildByocJsonRenderAgent(): Promise<StrandsAgent> {
const strandsAgent = new Agent({
model: await createModel(),
systemPrompt: BYOC_JSON_RENDER_SYSTEM_PROMPT,
tools: [],
});
return new StrandsAgent({
agent: strandsAgent,
name: "byoc_json_render",
description:
"json-render flat-spec generator for the declarative-json-render demo.",
});
}
// ---------------------------------------------------------------------------
// A2UI Fixed Schema (declarative-generative-ui) — dedicated backend tool.
// ---------------------------------------------------------------------------
//
// Unlike the dynamic A2UI demo (which relies on the adapter auto-injecting a
// `generate_a2ui` tool to *generate* a surface), the fixed-schema demo wires a
// single plain backend tool — `display_flight` — that returns the
// `a2ui_operations` envelope (createSurface -> updateComponents ->
// updateDataModel). The component tree is fixed and authored ahead of time
// (./a2ui_schemas/flight_schema.json); only the *data* changes per call. The
// runtime A2UIMiddleware detects the envelope in the tool result and paints.
// No sub-agent, no generation, no `generate_a2ui` injection.
//
// The schema's component names + data paths must match the showcase frontend
// catalog at src/app/demos/a2ui-fixed-schema/a2ui/{definitions,renderers,
// catalog}.ts — catalog id `copilotkit://flight-fixed-catalog`. This mirrors
// the canonical langgraph-python demo (src/agents/a2ui_fixed.py).
const _A2UI_DIR = dirname(fileURLToPath(import.meta.url));
const A2UI_FIXED_CATALOG_ID = "copilotkit://flight-fixed-catalog";
const A2UI_FIXED_SURFACE_ID = "flight-fixed-schema";
// Fixed, pre-authored component layout. Loaded from JSON so it can be authored
// and reviewed independently of the agent code.
const FLIGHT_SCHEMA: Array<Record<string, unknown>> = JSON.parse(
readFileSync(join(_A2UI_DIR, "a2ui_schemas", "flight_schema.json"), "utf-8"),
);
const A2UI_FIXED_SYSTEM_PROMPT =
"You help users find flights. When asked about a flight, call " +
"`display_flight` exactly ONCE with origin, destination, airline, and " +
'price. Use short airport codes (e.g. "SFO", "JFK") for ' +
'origin/destination and a price string like "$289". The tool\'s return ' +
"value is an A2UI surface descriptor — the flight card is already rendered " +
"to the user; do NOT call `display_flight` again for the same trip and do " +
"NOT repeat the flight details in text. After the tool returns, reply with " +
"one short confirmation sentence and stop.";
/**
* Dedicated agent for the A2UI fixed-schema demo. Returns the envelope as a
* plain OBJECT (not a JSON string): the Strands TS SDK wraps an object
* tool-return in a `json` content block the adapter reads and re-stringifies
* into the TOOL_CALL_RESULT the client A2UIMiddleware scans for
* `a2ui_operations`. (A bare string return lands in no content block and the
* result comes through empty — unlike the Python SDK, which wraps strings.)
*/
export async function buildA2uiFixedSchemaAgent(): Promise<StrandsAgent> {
const displayFlight = tool({
name: "display_flight",
description:
"Show a flight card for the given trip. Use short airport codes " +
'(e.g. "SFO", "JFK") for origin/destination and a price string like ' +
'"$289". After this tool returns, the flight card is already rendered ' +
"to the user via the A2UI surface — do NOT call it again for the same " +
"flight; reply with one short confirmation sentence and stop.",
inputSchema: z.object({
origin: z.string().describe('Origin airport code, e.g. "SFO".'),
destination: z.string().describe('Destination airport code, e.g. "JFK".'),
airline: z.string().describe('Airline name, e.g. "United".'),
price: z.string().describe('Price string, e.g. "$289".'),
}),
callback: ({ origin, destination, airline, price }) => ({
[A2UI_OPERATIONS_KEY]: [
createSurface(A2UI_FIXED_SURFACE_ID, A2UI_FIXED_CATALOG_ID),
updateComponents(A2UI_FIXED_SURFACE_ID, FLIGHT_SCHEMA),
updateDataModel(A2UI_FIXED_SURFACE_ID, {
origin,
destination,
airline,
price,
}),
],
}),
});
const strandsAgent = new Agent({
// Chat Completions API: the Responses adapter buffers tool-call argument
// deltas, which would defeat A2UI's progressive surface streaming.
model: await createModel({ openaiApi: "chat" }),
systemPrompt: A2UI_FIXED_SYSTEM_PROMPT,
tools: [displayFlight],
});
return new StrandsAgent({
agent: strandsAgent,
name: "a2ui_fixed_schema",
description:
"A2UI surface from a fixed, pre-authored schema (direct backend tool)",
});
}
// ---------------------------------------------------------------------------
// A2UI Dynamic Schema (declarative-gen-ui) — adapter auto-injects generate_a2ui.
// ---------------------------------------------------------------------------
//
// Unlike the fixed-schema demo (which wires a `display_flight` tool returning a
// pre-authored envelope), the dynamic demo lets the agent *generate* the
// surface layout on the fly. The Next.js route
// (app/api/copilotkit-declarative-gen-ui/route.ts) sets
// `a2ui: { injectA2UITool: true, defaultCatalogId: "declarative-gen-ui-catalog" }`;
// the runtime forwards the flag, the Strands adapter auto-injects a
// `generate_a2ui` tool and drives a secondary render planner. The
// `config.a2ui` block below supplies the catalog id stamped into generated
// surfaces and the composition guide that teaches the planner the page's
// catalog. Mirrors the ag-ui dynamic-schema reference example.
//
// The compositionGuide MUST describe the catalog the page registers at
// src/app/demos/declarative-gen-ui/a2ui/{definitions,renderers,catalog}.ts
// (catalog id `declarative-gen-ui-catalog`): Card / StatusBadge / Metric /
// InfoRow / PrimaryButton / PieChart / BarChart / DataTable, composed inside
// the basic catalog's Row / Column / Text (`includeBasicCatalog: true`).
//
// Grounding dataset + composition rules are kept in spirit with the frontend
// `sales-context.ts` (SALES_DATASET + COMPOSITION_RULES) the page registers via
// `useAgentContext`. The frontend context steers the PRIMARY agent; this
// compositionGuide is the channel the adapter feeds to the secondary
// `render_a2ui` planner (it gets `guidelines`, not the frontend App Context),
// so the planner is self-contained.
const A2UI_DYNAMIC_CATALOG_ID = "declarative-gen-ui-catalog";
const A2UI_DYNAMIC_SALES_DATASET = `Vantage Threads (fictional B2B apparel company) — Q2 sales data. Ground every visual in these numbers; invent only plausible details consistent with them.
- Quarterly revenue: $4.2M (up 12% QoQ). New customers: 186 (up 8%). Win rate: 31% (down 2pts). Avg deal size: $22.6k (up 5%).
- Revenue by region: North America $1.9M, EMEA $1.3M, APAC $720k, LATAM $280k.
- Monthly revenue: Jan $1.21M, Feb $1.34M, Mar $1.65M, Apr $1.38M, May $1.42M, Jun $1.40M.
- Reps (vs quota): Dana Whitfield 124%, Marcus Lee 108%, Priya Sharma 97%, Tom Okafor 88%, Elena Vasquez 71%.
- At-risk: total $615k ARR across 3 accounts — Northwind Retail ($340k renewal, no contact 6 weeks; severity high), Cascadia Outfitters ($180k, champion left; severity medium), Atlas Goods ($95k, stalled legal review; severity medium).
- Biggest account: Meridian Apparel Group — owner Dana Whitfield, region North America, ARR $612k, renewal Sep 30, last contact 3 days ago, health green, 4 open opportunities worth $210k.
- Meridian revenue by product line: Outerwear $260k, Footwear $180k, Accessories $112k, Custom $60k.`;
const A2UI_DYNAMIC_COMPOSITION_RULES = `Use ONLY these exact component names (the registered catalog — any other name fails to render): Card, Column, Row, Text, Metric, PieChart, BarChart, DataTable, StatusBadge, InfoRow, PrimaryButton. The single-value KPI tile component is named exactly "Metric" (NOT "MetricTile" or "MetricCard").
Pick A2UI components by the shape of the question — never ask which chart the user wants:
1. Overall snapshot / "sales dashboard" → a Column (gap 16) whose first child is a Row (gap 16) of 4 Metric components (each with trend + trendValue), followed by a Row with a PieChart (revenue by region) next to a BarChart (monthly revenue, all six months Jan-Jun). Do NOT wrap the dashboard in a surrounding Card — the charts carry their own card chrome. Do NOT use StatusBadge, DataTable, or InfoRow here.
2. Rep / team performance → a Column (gap 16) with a Card containing a DataTable (columns: rep, attainment, pipeline) next to or above a BarChart of quota attainment % per rep — no StatusBadge or InfoRow.
3. Risk / health checks → a Column (gap 16): first a Row (gap 16) of 3 Metric components (ARR at risk $615k trend down, accounts at risk 3, biggest exposure Northwind $340k), then a Row (gap 16) with one compact Card per at-risk account (title = account name, subtitle = ARR at stake) containing a StatusBadge (error for high severity, warning otherwise) above a one-line Text with the reason and the recommended next action — no DataTable or InfoRow.
4. Single account/entity details → a Row (gap 16) with a Card of InfoRow facts (owner, region, ARR, renewal date, last contact) next to a PieChart of that account's revenue by product line — no DataTable or StatusBadge.
5. Part-of-whole follow-ups → PieChart; trends or comparisons over time/categories → BarChart.
Compose generously — a dashboard should feel like a real analytics product, not a single widget.`;
const A2UI_DYNAMIC_COMPOSITION_GUIDE = `${A2UI_DYNAMIC_SALES_DATASET}\n\n${A2UI_DYNAMIC_COMPOSITION_RULES}`;
// Mirrors the langgraph-python demo's a2ui_dynamic.py SYSTEM_PROMPT.
const A2UI_DYNAMIC_SYSTEM_PROMPT =
"You are the embedded sales analyst for Vantage Threads, the fictional " +
"B2B apparel company described in your App Context. Answer every " +
"business question by calling `generate_a2ui` to draw a rich visual " +
"surface, and keep the chat reply to one short sentence.\n\n" +
"Ground every number in the sales dataset from App Context — never " +
"invent figures that contradict it. Follow the dashboard composition " +
"rules from App Context when choosing components: pick the component " +
"by the shape of the question (snapshot → composed KPI dashboard with " +
"charts; team performance → table; risk → status badges; single " +
"account → info rows; part-of-whole → pie; trend/comparison → bar). " +
"Never ask the user which chart they want. `generate_a2ui` takes no " +
"arguments and handles the rendering automatically. Compose " +
"generously — a dashboard should feel like a real analytics product, " +
"not a single widget.";
/**
* Dedicated agent for the A2UI dynamic-schema demo. Wires NO `generate_a2ui`
* tool — the runtime's `injectA2UITool: true` makes the adapter auto-inject it
* and drive a secondary render planner to GENERATE the surface.
*/
export async function buildA2uiDynamicAgent(): Promise<StrandsAgent> {
const strandsAgent = new Agent({
// Chat Completions API: the Responses adapter buffers tool-call argument
// deltas, which would defeat A2UI's progressive surface streaming.
model: await createModel({ openaiApi: "chat" }),
systemPrompt: A2UI_DYNAMIC_SYSTEM_PROMPT,
});
const config: StrandsAgentConfig = {
a2ui: {
defaultCatalogId: A2UI_DYNAMIC_CATALOG_ID,
guidelines: { compositionGuide: A2UI_DYNAMIC_COMPOSITION_GUIDE },
},
};
return new StrandsAgent({
agent: strandsAgent,
name: "a2ui_dynamic_schema",
description:
"Dynamic A2UI surfaces generated on the fly (auto-injected tool)",
config,
});
}
// ---------------------------------------------------------------------------
// A2UI Error Recovery (a2ui-recovery) — adapter auto-injects + runs recovery.
// ---------------------------------------------------------------------------
//
// Same auto-injected dynamic-schema setup as buildA2uiDynamicAgent, but the
// aimock fixtures force the inner render_a2ui to emit free-form/sloppy args
// (heal pill) or a structurally-invalid surface on every attempt (exhaust
// pill). The Strands adapter runs the toolkit validate->retry recovery loop on
// its auto-inject path (default 3 attempts) and returns the
// a2ui_recovery_exhausted hard-fail envelope when the cap is hit — so this
// agent wires NO tool, unlike the langgraph/ADK siblings (which own the tool
// explicitly via getA2UITools + injectA2UITool:false). Mirrors the ag-ui dojo
// aws-strands recovery example.
/**
* Dedicated agent for the A2UI error-recovery demo. Wires NO `generate_a2ui`
* tool — the runtime's `injectA2UITool: true` makes the adapter auto-inject it,
* drive the secondary render planner, and run the recovery loop.
*/
export async function buildA2uiRecoveryAgent(): Promise<StrandsAgent> {
const strandsAgent = new Agent({
// Chat Completions API: the Responses adapter buffers tool-call argument
// deltas, which would defeat A2UI's progressive surface streaming.
model: await createModel({ openaiApi: "chat" }),
systemPrompt: A2UI_DYNAMIC_SYSTEM_PROMPT,
});
const config: StrandsAgentConfig = {
a2ui: {
defaultCatalogId: A2UI_DYNAMIC_CATALOG_ID,
guidelines: { compositionGuide: A2UI_DYNAMIC_COMPOSITION_GUIDE },
},
};
return new StrandsAgent({
agent: strandsAgent,
name: "a2ui_recovery",
description:
"Dynamic A2UI with automatic error recovery (auto-injected tool)",
config,
});
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { sseChunkByteLength } from "./cvdiag-backend-strands.js";
describe("sseChunkByteLength", () => {
it("reports the byte length of a Uint8Array chunk (not 0)", () => {
// A multi-byte payload: "héllo" — the é encodes to 2 bytes in UTF-8.
const chunk = new TextEncoder().encode("héllo"); // Uint8Array, byteLength 6
expect(chunk).toBeInstanceOf(Uint8Array);
expect(chunk).not.toBeInstanceOf(Buffer);
expect(sseChunkByteLength(chunk)).toBe(chunk.byteLength);
expect(sseChunkByteLength(chunk)).toBe(6);
});
it("measures string chunks via UTF-8 byte length", () => {
expect(sseChunkByteLength("héllo")).toBe(6);
});
it("measures Buffer chunks", () => {
const buf = Buffer.from("héllo", "utf8");
expect(sseChunkByteLength(buf)).toBe(6);
});
it("returns 0 for unknown chunk types", () => {
expect(sseChunkByteLength(undefined)).toBe(0);
expect(sseChunkByteLength(42)).toBe(0);
});
});
@@ -0,0 +1,312 @@
/**
* cvdiag-backend-strands.ts — AGENT-SIDE CVDIAG backend instrumentation for the
* two-process strands-typescript integration.
*
* Unlike the four in-process TS integrations (langgraph-typescript,
* claude-sdk-typescript, mastra, built-in-agent), which wrap their Next route
* with `withCvdiagBackend` because the model runs in-process, strands runs the
* model in THIS Express agent process. The Next route is a bare HttpAgent proxy
* and `@ag-ui/aws-strands@0.2.3` drops inbound x-* before `agent.run()`, so the
* real backend boundary (the outbound LLM call) AND the per-request header
* forwarding MUST live here.
*
* This module exposes a single Express middleware mounted BEFORE the aws-strands
* POST handler on the same path. It does TWO things per request:
* 1. ALWAYS seeds the header-forwarding ALS (`withForwardedHeaders`) so the
* outbound OpenAI fetch injects the inbound x-* (incl. `X-AIMock-Strict`).
* Header forwarding is INDEPENDENT of the emitter gate.
* 2. When `CVDIAG_BACKEND_EMITTER` is truthy, emits the backend.* CVDIAG
* boundaries around the streamed AG-UI response, adopting the inbound
* `x-test-id` as the cross-layer JOIN key.
*
* Boundary names + metadata keys are taken VERBATIM from the in-process
* precedent (`integrations/langgraph-typescript/src/cvdiag-backend.ts`) so the
* 11 BACKEND_BOUNDARIES validate against the staged `schema.ts` and join in
* cli-classify. No new boundary strings are invented.
*
* GUARDED BY `CVDIAG_BACKEND_EMITTER` (default OFF): when unset the middleware
* is forwarding-only (still seeds the ALS) and emits nothing. CVDIAG is pure
* instrumentation — every emit/flush is best-effort and any internal error
* degrades to `next()` (the request is never blocked or failed by CVDIAG).
*
* The emitter barrel is the co-located, build-context-staged copy under
* `../cvdiag/cvdiag-emitter` (staged by `bin/showcase cvdiag-stage-ts`). The
* Express agent project has NO `@/` path alias (that is the Next project's
* tsconfig), so it is imported by RELATIVE path with a `.js` extension (the
* bundler/tsx resolver maps it to the `.ts` source).
*/
import type { Request, Response, NextFunction } from "express";
import {
createCvdiagFetchPbWriterFromEnv,
CvdiagEmitter,
filterEdgeHeaders,
mintTestId,
scrubSecrets,
} from "../cvdiag/cvdiag-emitter.js";
import type {
CvdiagEnvelope,
CvdiagOutcome,
CvdiagPbWriter,
} from "../cvdiag/cvdiag-emitter.js";
import { withForwardedHeaders } from "./header-forwarding.js";
/** Env flag that gates the emitter. Default OFF. */
const ENABLE_ENV = "CVDIAG_BACKEND_EMITTER";
/** Truthy check for the gate (1 / true / yes / on, case-insensitive). */
function isEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const raw = env[ENABLE_ENV];
if (raw === undefined) return false;
return /^(1|true|yes|on)$/i.test(raw.trim());
}
/**
* Construct the concrete writer-role PB writer ONCE (env read once at module
* load). Returns undefined when `CVDIAG_PB_URL` is unset — in which case the
* emitter is writer-less (stdout-only, no rows persist), matching the precedent.
*/
const pbWriter: CvdiagPbWriter | undefined = createCvdiagFetchPbWriterFromEnv();
/**
* Read the inbound probe `x-test-id` — the CROSS-LAYER JOIN key. The probe mints
* a per-run id (`d6-<slug>-<runId>`) and forwards it on every request; the
* backend adopts it as the envelope `test_id` so its rows join the probe's rows.
* Returns the trimmed value or undefined (→ the emitter mints a fresh UUIDv7).
*/
function inboundTestId(req: Request): string | undefined {
try {
const raw = req.header("x-test-id");
const trimmed = raw?.trim();
return trimmed && trimmed.length > 0 ? trimmed : undefined;
} catch {
return undefined;
}
}
/** Snapshot the allow-listed edge headers off an Express request. */
function edgeHeadersFrom(req: Request): ReturnType<typeof filterEdgeHeaders> {
const bag: Record<string, string | null> = {};
for (const [k, v] of Object.entries(req.headers)) {
bag[k] = Array.isArray(v) ? v.join(",") : (v ?? null);
}
return filterEdgeHeaders(bag);
}
/**
* Byte length of an SSE `res.write` chunk for `payload_size_bytes`. Strings are
* measured via `Buffer.byteLength`; any `ArrayBufferView` (Buffer, Uint8Array,
* DataView, etc.) reports its `byteLength` (for Buffer this equals `.length`).
* Anything else (no chunk / unknown type) is 0.
*/
export function sseChunkByteLength(chunk: unknown): number {
return typeof chunk === "string"
? Buffer.byteLength(chunk)
: ArrayBuffer.isView(chunk)
? chunk.byteLength
: 0;
}
export interface StrandsCvdiagOptions {
/** Integration slug ("strands-typescript"). */
slug: string;
/** Agent name to stamp on backend.agent.enter. */
agentName: string;
/** Provider label for backend.llm.call.* (defaults to "openai"). */
provider?: string;
/** Best-effort model identifier for backend.agent.enter / llm.call.*. */
modelId?: string;
}
/**
* Express middleware mounted BEFORE the aws-strands POST handler on the same
* path. Always seeds the forwarding ALS (so the outbound OpenAI fetch injects
* x-*); when the emitter is enabled it also emits the backend.* boundaries
* around the streamed AG-UI response. Crash-proof: any CVDIAG error degrades to
* `next()`.
*/
export function strandsCvdiagMiddleware(opts: StrandsCvdiagOptions) {
return (req: Request, res: Response, next: NextFunction): void => {
// Header forwarding (§3) is independent of the emitter gate: ALWAYS run the
// remainder of the request inside the forwarding ALS scope. Because the
// aws-strands handler + agent.run() + the streamed res.write() calls all run
// synchronously-then-async inside this next() chain, AsyncLocalStorage
// propagates the snapshot across the awaits to the outbound fetch.
withForwardedHeaders(req, () => {
if (!isEnabled()) {
next();
return;
}
try {
runWithEmitter(req, res, next, opts);
} catch {
// CVDIAG must never block the request.
next();
}
});
};
}
function runWithEmitter(
req: Request,
res: Response,
next: NextFunction,
opts: StrandsCvdiagOptions,
): void {
const provider = opts.provider ?? "openai";
const modelId = opts.modelId ?? "unknown";
const emitter = new CvdiagEmitter({
layer: "backend",
autoFlush: true,
pbWriter,
});
// CROSS-LAYER JOIN: adopt the inbound probe x-test-id as the envelope test_id;
// traceId is a fresh per-request UUIDv7 kept distinct from the shared run id.
const testId = inboundTestId(req);
const traceId = mintTestId();
const startedAt = Date.now();
const edgeHeaders = (() => {
try {
return edgeHeadersFrom(req);
} catch {
return undefined;
}
})();
const emit = (
boundary: Parameters<CvdiagEmitter["emit"]>[0]["boundary"],
outcome: CvdiagOutcome,
metadata: Record<string, unknown>,
durationMs: number | null = null,
): CvdiagEnvelope | null =>
emitter.emit({
layer: "backend",
boundary,
slug: opts.slug,
demo: opts.slug,
outcome,
testId,
traceId,
edgeHeaders,
metadata,
durationMs,
});
// 1) backend.request.ingress
emit("backend.request.ingress", "info", {
method: req.method,
path: scrubSecrets(req.path),
content_length: Number(req.header("content-length")) || null,
});
// 2) backend.agent.enter
emit("backend.agent.enter", "info", {
agent_name: opts.agentName,
model_id: modelId,
});
// 3) backend.llm.call.start
emit("backend.llm.call.start", "info", {
provider,
model: modelId,
prompt_token_count_estimate: 0,
});
// Hook the Express response stream. The aws-strands handler calls
// res.write()/res.end(); wrap them to emit sse.first_byte / sse.event /
// response.complete / agent.exit. Wrappers forward verbatim and never throw
// into the underlying write.
let firstByte = false;
let sseCount = 0;
let terminalsEmitted = false;
const origWrite = res.write.bind(res);
const origEnd = res.end.bind(res);
res.write = ((chunk: unknown, ...rest: unknown[]) => {
try {
if (!firstByte) {
firstByte = true;
emit("backend.sse.first_byte", "ok", {
delta_ms_from_ingress: Date.now() - startedAt,
});
}
const size = sseChunkByteLength(chunk);
emit("backend.sse.event", "info", {
event_type: "chunk",
payload_size_bytes: size,
sequence_num: sseCount,
});
sseCount += 1;
} catch {
/* instrumentation must never break the stream */
}
return (origWrite as (...a: unknown[]) => boolean)(chunk, ...rest);
}) as typeof res.write;
res.end = ((...args: unknown[]) => {
try {
if (!terminalsEmitted) {
terminalsEmitted = true;
const dur = Date.now() - startedAt;
const outcome: CvdiagOutcome = res.statusCode >= 400 ? "err" : "ok";
// 5) backend.llm.call.response
emit(
"backend.llm.call.response",
outcome,
{
provider,
model: modelId,
response_token_count: null,
latency_ms: dur,
error_class: null,
},
dur,
);
// 10) backend.response.complete
emit(
"backend.response.complete",
outcome,
{
http_status: res.statusCode,
sse_event_count: sseCount,
total_duration_ms: dur,
},
dur,
);
// 9) backend.agent.exit
emit(
"backend.agent.exit",
outcome,
{ terminal_outcome: outcome, total_duration_ms: dur },
dur,
);
void emitter.flush();
}
} catch {
/* never throw into res.end */
}
return (origEnd as (...a: unknown[]) => Response)(...args);
}) as typeof res.end;
res.once("error", () => {
try {
if (!terminalsEmitted) {
terminalsEmitted = true;
const dur = Date.now() - startedAt;
emit("backend.sse.aborted", "err", {
termination_kind: "chunk_error",
bytes_before_abort: 0,
});
emit(
"backend.agent.exit",
"err",
{ terminal_outcome: "err", total_duration_ms: dur },
dur,
);
void emitter.flush();
}
} catch {
/* never throw */
}
});
next();
}
@@ -0,0 +1,86 @@
/**
* header-forwarding.ts — per-request inbound x-* header forwarding for the
* strands-typescript Express AGENT process.
*
* Why this exists: strands-typescript is a TWO-process integration. The Next
* route is a bare HttpAgent proxy; the model call to aimock originates in THIS
* Express agent process. `@ag-ui/aws-strands@0.2.3`'s express handler reads only
* `req.body` + the `accept` header and calls `agent.run()` with NO inbound
* headers, so `X-AIMock-Strict` / `x-test-id` / `x-aimock-context` / `x-diag-*`
* are dropped before the model is invoked. Without this shim the outbound
* OpenAI call to aimock carries only the STATIC `x-aimock-context` slug
* (model-factory.ts) and never the inbound `X-AIMock-Strict`, so a probe's
* strict verification silently falls through on a fixture miss.
*
* The fix mirrors the in-process precedent
* (`integrations/built-in-agent/src/lib/header-forwarding.ts`), adapted for an
* Express `Request` (whose `.headers` is a plain object, not a `Headers`):
* - `withForwardedHeaders(req, fn)` snapshots inbound `x-*` headers into an
* AsyncLocalStorage scope and runs `fn` (the rest of the request) inside it.
* The strands cvdiag middleware (cvdiag-backend-strands.ts) calls this
* BEFORE falling through to the aws-strands handler, so `agent.run()` and
* the outbound `OpenAIModel.stream()` execute within the ALS scope.
* - `forwardingFetch` reads the ALS-bound headers at outbound-call time and
* merges them onto every request the OpenAI SDK makes. It is passed as
* `clientConfig.fetch` to the strands `OpenAIModel` (model-factory.ts).
*
* NEVER hardcodes strict on: only headers PRESENT on the inbound request are
* forwarded. When no x-* are in scope, `forwardingFetch` is byte-identical to a
* plain `fetch`, so ordinary demo traffic (no `X-AIMock-Strict`) proxies
* unchanged.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import type { Request } from "express";
const headersStorage = new AsyncLocalStorage<Record<string, string>>();
/** Extract the x-* headers off an Express request's plain header object. */
function extractXHeaders(req: Request): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
const lower = key.toLowerCase();
if (!lower.startsWith("x-")) continue;
if (value === undefined) continue;
out[lower] = Array.isArray(value) ? value.join(",") : String(value);
}
return out;
}
/**
* Run `fn` with an ALS-bound snapshot of inbound x-* headers. Any outbound
* fetch made by the OpenAI client during `fn` (which includes the whole
* downstream aws-strands handler + `agent.run()` + the streamed response)
* sees these headers and merges them into the request.
*/
export function withForwardedHeaders<T>(req: Request, fn: () => T): T {
return headersStorage.run(extractXHeaders(req), fn);
}
/** Return the ALS-bound headers (or an empty map when not in scope). */
function getForwardedHeaders(): Record<string, string> {
return headersStorage.getStore() ?? {};
}
/**
* `clientConfig.fetch` for the strands `OpenAIModel`. Injects the ALS-bound
* inbound x-* headers (incl. `X-AIMock-Strict`, `x-test-id`, `x-aimock-context`,
* `x-diag-*`) onto every outbound OpenAI call. Byte-identical to a plain
* `fetch` when no x-* are in scope, so demo traffic is unaffected.
*
* Precedence: uses `if (!merged.has(k)) merged.set(k, v)` so it never clobbers a
* header the OpenAI SDK already set from `clientConfig.defaultHeaders` (the
* static `x-aimock-context` slug stays authoritative); inbound `X-AIMock-Strict`
* / `x-test-id` / `x-diag-*` are ADDED.
*/
export const forwardingFetch: typeof fetch = (input, init) => {
const forwarded = getForwardedHeaders();
if (Object.keys(forwarded).length === 0) {
return fetch(input, init);
}
const merged = new Headers(init?.headers);
for (const [k, v] of Object.entries(forwarded)) {
if (!merged.has(k)) merged.set(k, v);
}
return fetch(input, { ...init, headers: merged });
};
@@ -0,0 +1,285 @@
/**
* Pure tool implementations shared by the Strands showcase agent.
*
* Mirrors the langgraph-typescript `shared-tools/*` impls (which in turn
* mirror `showcase/shared/python/tools/*.py`). Kept framework-agnostic —
* the Strands `tool()` wrappers in `tools.ts` call these.
*/
// ---- Types ---------------------------------------------------------------
export type SalesStage =
| "prospect"
| "qualified"
| "proposal"
| "negotiation"
| "closed-won"
| "closed-lost";
export interface SalesTodo {
id: string;
title: string;
stage: SalesStage;
value: number;
dueDate: string;
assignee: string;
completed: boolean;
}
export interface Flight {
airline: string;
airlineLogo: string;
flightNumber: string;
origin: string;
destination: string;
date: string;
departureTime: string;
arrivalTime: string;
duration: string;
status: string;
statusColor: string;
price: string;
currency: string;
}
export interface WeatherResult {
city: string;
temperature: number;
humidity: number;
wind_speed: number;
feels_like: number;
conditions: string;
}
// ---- get_weather ---------------------------------------------------------
const CONDITIONS = [
"Sunny",
"Partly Cloudy",
"Cloudy",
"Overcast",
"Light Rain",
"Heavy Rain",
"Thunderstorm",
"Snow",
"Foggy",
"Windy",
];
/** Deterministic mulberry32 PRNG so a city always yields the same weather. */
function seededRandom(seed: number): () => number {
let t = seed;
return () => {
t = (t + 0x6d2b79f5) | 0;
let v = Math.imul(t ^ (t >>> 15), 1 | t);
v = (v + Math.imul(v ^ (v >>> 7), 61 | v)) ^ v;
return ((v ^ (v >>> 14)) >>> 0) / 4294967296;
};
}
function hashString(s: string): number {
let hash = 0;
for (let i = 0; i < s.length; i++) {
hash = (Math.imul(31, hash) + s.charCodeAt(i)) | 0;
}
return hash;
}
function randInt(rng: () => number, min: number, max: number): number {
return Math.floor(rng() * (max - min + 1)) + min;
}
function randChoice<T>(rng: () => number, arr: T[]): T {
return arr[Math.floor(rng() * arr.length)]!;
}
export function getWeatherImpl(city: string): WeatherResult {
const rng = seededRandom(hashString(city.toLowerCase()));
const temperature = randInt(rng, 20, 95);
return {
city,
temperature,
humidity: randInt(rng, 30, 90),
wind_speed: randInt(rng, 2, 30),
feels_like: temperature + randInt(rng, -5, 5),
conditions: randChoice(rng, CONDITIONS),
};
}
// ---- query_data (mock financial data) ------------------------------------
export interface DataRow {
date: string;
category: string;
subcategory: string;
amount: string;
type: string;
notes: string;
}
function seededLcg(seed: number): () => number {
let s = seed;
return () => {
s = (s * 1103515245 + 12345) & 0x7fffffff;
return s / 0x7fffffff;
};
}
function generateMockData(): DataRow[] {
const rand = seededLcg(42);
const categories: Array<{
category: string;
subcategory: string;
type: string;
}> = [
{
category: "Revenue",
subcategory: "Enterprise Subscriptions",
type: "income",
},
{ category: "Revenue", subcategory: "Pro Tier Upgrades", type: "income" },
{ category: "Revenue", subcategory: "API Usage Overages", type: "income" },
{ category: "Revenue", subcategory: "Consulting Services", type: "income" },
{ category: "Revenue", subcategory: "Marketplace Sales", type: "income" },
{
category: "Expenses",
subcategory: "Engineering Salaries",
type: "expense",
},
{ category: "Expenses", subcategory: "Product Team", type: "expense" },
{
category: "Expenses",
subcategory: "AWS Infrastructure",
type: "expense",
},
{ category: "Expenses", subcategory: "Marketing", type: "expense" },
{ category: "Expenses", subcategory: "Customer Success", type: "expense" },
{ category: "Expenses", subcategory: "AI Model Costs", type: "expense" },
];
const notes: Record<string, string> = {
"Enterprise Subscriptions": "3 new enterprise customers",
"Pro Tier Upgrades": "31 upgrades + reduced churn",
"API Usage Overages": "Heavy usage from top-10 accounts",
"Consulting Services": "2 implementation projects",
"Marketplace Sales": "Partner integrations revenue",
"Engineering Salaries": "7 engineers + 2 contractors",
"Product Team": "PM + designers + QA",
"AWS Infrastructure": "Compute + storage + bandwidth",
Marketing: "Paid ads + content + events",
"Customer Success": "3 CSMs + tooling",
"AI Model Costs": "OpenAI + Anthropic API spend",
};
const rows: DataRow[] = [];
const months = ["01", "02", "03", "04", "05", "06"];
for (const month of months) {
for (const cat of categories) {
const baseAmount =
cat.type === "income"
? 15000 + Math.floor(rand() * 35000)
: 8000 + Math.floor(rand() * 40000);
const day = String(1 + Math.floor(rand() * 28)).padStart(2, "0");
rows.push({
date: `2026-${month}-${day}`,
category: cat.category,
subcategory: cat.subcategory,
amount: String(baseAmount),
type: cat.type,
notes: notes[cat.subcategory] ?? "",
});
}
}
return rows;
}
const MOCK_DATA: DataRow[] = generateMockData();
export function queryDataImpl(_query: string): DataRow[] {
return MOCK_DATA;
}
// ---- search_flights ------------------------------------------------------
export function searchFlightsImpl(flights: Flight[]): {
flights: Flight[];
schema: Record<string, unknown>;
} {
return { flights, schema: {} };
}
// ---- sales todos ---------------------------------------------------------
export const INITIAL_SALES_TODOS: SalesTodo[] = [
{
id: "st-001",
title: "Follow up with Acme Corp on enterprise proposal",
stage: "proposal",
value: 85000,
dueDate: "2026-04-15",
assignee: "Sarah Chen",
completed: false,
},
{
id: "st-002",
title: "Qualify lead from TechFlow demo request",
stage: "prospect",
value: 42000,
dueDate: "2026-04-18",
assignee: "Mike Johnson",
completed: false,
},
{
id: "st-003",
title: "Send contract to DataViz Inc for final review",
stage: "negotiation",
value: 120000,
dueDate: "2026-04-20",
assignee: "Sarah Chen",
completed: false,
},
];
export function manageSalesTodosImpl(todos: Partial<SalesTodo>[]): SalesTodo[] {
return todos.map((todo) => ({
id: todo.id || crypto.randomUUID(),
title: todo.title ?? "",
stage: todo.stage ?? "prospect",
value: todo.value ?? 0,
dueDate: todo.dueDate ?? "",
assignee: todo.assignee ?? "",
completed: todo.completed ?? false,
}));
}
// ---- roll_dice -----------------------------------------------------------
export interface RollDiceResult {
sides: number;
result: number;
}
export function rollDiceImpl(sides: number): RollDiceResult {
return { sides, result: 1 + Math.floor(Math.random() * sides) };
}
// ---- schedule_meeting (HITL gated) ---------------------------------------
export interface ScheduleMeetingResult {
status: "pending_approval";
reason: string;
duration_minutes: number;
message: string;
}
export function scheduleMeetingImpl(
reason: string,
durationMinutes = 30,
): ScheduleMeetingResult {
return {
status: "pending_approval",
reason,
duration_minutes: durationMinutes,
message: `Meeting request: ${reason} (${durationMinutes} min). Awaiting user time selection.`,
};
}
@@ -0,0 +1,121 @@
/**
* Shared model factory for the Strands TypeScript showcase agent.
*
* Mirrors the upstream AG-UI `aws-strands/typescript/examples/server/
* model-factory.ts` shape: same `MODEL_PROVIDER` env-var contract and
* provider defaults. The showcase routes everything through OpenAI chat
* completions by default so tool-call ARGUMENTS stream incrementally — the
* Responses adapter buffers `function_call_arguments.delta` and only emits
* the complete toolUse at `…arguments.done`, which breaks the progressive
* state-streaming demos (shared-state, gen-ui-agent).
*
* `OPENAI_BASE_URL` is honored so the agent can run behind the showcase
* aimock proxy (record/replay) without code changes — when set, every
* OpenAI call is routed there.
*
* Supported providers: `openai` (default), `anthropic`, `bedrock`.
*/
import type { Model } from "@strands-agents/sdk";
import { forwardingFetch } from "./header-forwarding.js";
/**
* aimock keys its fixtures on the `x-aimock-context` header of the outbound
* OpenAI request — it identifies which integration's fixtures to match. An
* integration's context is constant (its slug), so we attach it statically as
* a default header on the OpenAI client rather than threading the inbound
* request header through the adapter. Harmless against real OpenAI (unknown
* headers are ignored); override via the AIMOCK_CONTEXT env var if needed.
*/
export const AIMOCK_CONTEXT =
process.env.AIMOCK_CONTEXT ?? "strands-typescript";
export interface CreateModelOptions {
/**
* Request reasoning/thinking content from the provider. Defaults to
* `false`. Only enable for demos that render reasoning in the UI.
*/
reasoning?: boolean;
/**
* OpenAI API mode. Defaults to `"chat"` for the showcase so tool-call
* arguments stream incrementally. Pass `"responses"` to use the Responses
* API.
*/
openaiApi?: "chat" | "responses";
}
export async function createModel(
options: CreateModelOptions = {},
): Promise<Model> {
const provider = (process.env.MODEL_PROVIDER ?? "openai").toLowerCase();
const reasoning = options.reasoning ?? false;
if (provider === "openai") {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error(
"OPENAI_API_KEY environment variable is required when MODEL_PROVIDER=openai. " +
"Set it in your .env file or environment.",
);
}
const { OpenAIModel } = await import("@strands-agents/sdk/models/openai");
// OPENAI_BASE_URL routes through aimock during showcase record/replay.
const baseURL = process.env.OPENAI_BASE_URL;
return new OpenAIModel({
apiKey,
modelId: process.env.MODEL_ID ?? "gpt-4o",
// Default to chat completions (incremental tool-arg streaming).
api: options.openaiApi ?? "chat",
...(reasoning
? { params: { reasoning: { effort: "medium", summary: "auto" } } }
: {}),
clientConfig: {
...(baseURL ? { baseURL } : {}),
// Identify this integration to aimock so it matches the right fixtures.
defaultHeaders: { "x-aimock-context": AIMOCK_CONTEXT },
// Per-request inbound x-* forwarding (incl. X-AIMock-Strict / x-test-id
// / x-diag-*). The OpenAI client is built ONCE at startup, but
// forwardingFetch reads an AsyncLocalStorage snapshot per outbound call
// (seeded by the Express cvdiag/forwarding middleware around
// agent.run()), so per-request headers flow correctly. It never
// clobbers the static x-aimock-context above, and is byte-identical to
// a plain fetch when no x-* are in scope (demo traffic unaffected).
fetch: forwardingFetch,
},
});
}
if (provider === "anthropic") {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
throw new Error(
"ANTHROPIC_API_KEY environment variable is required when MODEL_PROVIDER=anthropic.",
);
}
const { AnthropicModel } =
await import("@strands-agents/sdk/models/anthropic");
return new AnthropicModel({
apiKey,
modelId: process.env.MODEL_ID ?? "claude-sonnet-4-6",
});
}
if (provider === "bedrock") {
const { BedrockModel } = await import("@strands-agents/sdk");
return new BedrockModel({
modelId: process.env.MODEL_ID ?? "global.anthropic.claude-sonnet-4-6",
...(reasoning
? {
temperature: 1,
additionalRequestFields: {
thinking: { type: "enabled", budget_tokens: 2000 },
},
}
: {}),
});
}
throw new Error(
`Unknown MODEL_PROVIDER: ${provider}. Supported: openai, anthropic, bedrock`,
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
{
"name": "strands-typescript-agent",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "node --import tsx --watch server.ts",
"start": "node --import tsx server.ts"
},
"dependencies": {
"@ag-ui/a2ui-toolkit": "0.0.4",
"@ag-ui/aws-strands": "0.2.3",
"@ag-ui/client": "0.0.57",
"@ag-ui/core": "0.0.57",
"@ag-ui/encoder": "0.0.57",
"@modelcontextprotocol/sdk": "1.29.0",
"@opentelemetry/api": "1.9.1",
"@strands-agents/sdk": "1.6.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"openai": "^6.7.0",
"tsx": "^4.19.3",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.9.0",
"typescript": "^5.7.0"
}
}
@@ -0,0 +1,124 @@
/**
* System prompts for the Strands TypeScript showcase agents.
*
* Mirrors the Python sibling (`showcase/integrations/strands/src/agents/`):
* one shared showcase agent prompt plus per-demo specialized prompts for the
* tool-free structured-output agents (byoc-hashbrown, byoc-json-render,
* voice). The A2UI prompt sections from the Python sibling are intentionally
* omitted — this integration ships the base (non-A2UI) demo set.
*/
// gen-ui-agent planner addendum (set_steps contract). Gated on planning
// requests so the other demos are not regressed into emitting set_steps.
export const GEN_UI_AGENT_PROMPT = `When the user asks you to plan, organize, research, or otherwise orchestrate a multi-step task (e.g. "plan a product launch", "organize a team offsite", "research a competitor"), enter planner mode and follow this exact sequence:
1. Plan exactly 3 concrete steps and call \`set_steps\` ONCE with all three steps at status="pending".
2. Step 1: call \`set_steps\` with step 1 at status="in_progress", then call \`set_steps\` again with step 1 at status="completed".
3. Step 2: call \`set_steps\` with step 2 at status="in_progress", then call \`set_steps\` again with step 2 at status="completed".
4. Step 3: call \`set_steps\` with step 3 at status="in_progress", then call \`set_steps\` again with step 3 at status="completed".
5. Send ONE final conversational assistant message summarising the plan, then stop. Do not call any more tools after step 3 is completed.
Rules: ALWAYS pass the full list of 3 steps on every set_steps call (not a diff). Never call set_steps in parallel — wait for one call to return before the next. Use stable string ids like "step-1", "step-2", "step-3". Planner mode does NOT apply to weather / sales / shared-state / sub-agent demos — only enter it when the user explicitly asks you to plan or orchestrate.`;
export const SYSTEM_PROMPT = `You are a polished, professional demo assistant for CopilotKit. Keep responses brief and clear -- 1 to 2 sentences max.
You can:
- Chat naturally with the user
- Change the UI background when asked (via frontend tool)
- Query data and render charts (via query_data tool)
- Get weather information (via get_weather tool)
- Schedule meetings with the user (via schedule_meeting tool -- the user picks a time in the UI)
- Manage sales pipeline todos (via manage_sales_todos / get_sales_todos tools)
- Search flights and display rich cards (via search_flights tool)
- Generate step-by-step plans for user review (human-in-the-loop)
- Plan and execute multi-step tasks with a live progress card by calling \`set_steps\` on every status transition (see planner mode instructions below)
- Remember things the user tells you by calling \`set_notes\` with the FULL updated list of short note strings (existing notes + new). The UI renders these in a notes panel.
- Delegate work to specialised sub-agents when the user asks for research, drafting, or critique. Tools: \`research_agent\`, \`writing_agent\`, \`critique_agent\`. For non-trivial deliverables delegate in sequence research -> write -> critique. Pass relevant facts/draft through the \`task\` argument. The UI renders a live log of every delegation.
When discussing the sales pipeline, ALWAYS use the get_sales_todos tool to see the current list before mentioning, updating, or discussing todos with the user.
When the user shares preferences (name, tone, language, interests), they will be supplied in a system-style block at the top of every turn — respect them.
${GEN_UI_AGENT_PROMPT}`;
// Voice demo — tool-free, transcription + basic chat only.
export const VOICE_SYSTEM_PROMPT = "You are a helpful, concise assistant.";
// byoc-hashbrown — emits a strict hashbrown `{ "ui": [...] }` JSON envelope
// consumed by @hashbrownai/react. Tool-free pure structured-output generator.
export const BYOC_HASHBROWN_SYSTEM_PROMPT = `You are a sales analytics assistant that replies by emitting a single JSON object consumed by a streaming JSON parser on the frontend.
ALWAYS respond with a single JSON object of the form:
{
"ui": [
{ <componentName>: { "props": { ... } } },
...
]
}
Do NOT wrap the response in code fences. Do NOT include any preface or explanation outside the JSON object. The response MUST be valid JSON.
Available components and their prop schemas:
- "metric": { "props": { "label": string, "value": string } }
A KPI card. \`value\` is a pre-formatted string like "$1.2M" or "248".
- "pieChart": { "props": { "title": string, "data": string } }
A donut chart. \`data\` is a JSON-encoded STRING (embedded JSON) of an array of {label, value} objects with at least 3 segments.
- "barChart": { "props": { "title": string, "data": string } }
A vertical bar chart. \`data\` is a JSON-encoded STRING of an array of {label, value} objects with at least 3 bars, typically time-ordered.
- "dealCard": { "props": { "title": string, "stage": string, "value": number } }
A single sales deal. \`stage\` MUST be one of: "prospect", "qualified", "proposal", "negotiation", "closed-won", "closed-lost". \`value\` is a raw number (no currency symbol or comma).
- "Markdown": { "props": { "children": string } }
Short explanatory text. Use for section headings and brief summaries.
Rules:
- Always produce plausible sample data when the user asks for a dashboard or chart -- do not refuse for lack of data.
- Prefer 3-6 rows of data in charts; keep labels short.
- Do not emit components that are not listed above.
- \`data\` props on charts MUST be a JSON STRING -- escape inner quotes.`;
// byoc-json-render — emits a `@json-render/react` flat-spec object
// (`{ root, elements }`). Tool-free pure structured-output generator.
export const BYOC_JSON_RENDER_SYSTEM_PROMPT = `You are a sales-dashboard UI generator for a BYOC json-render demo.
When the user asks for a UI, respond with **exactly one JSON object** and nothing else — no prose, no markdown fences, no leading explanation. The object must match this schema (the "flat element map" format consumed by @json-render/react):
{
"root": "<id of the root element>",
"elements": {
"<id>": {
"type": "<component name>",
"props": { ... component-specific props ... },
"children": [ "<id>", ... ]
},
...
}
}
Available components (use each name verbatim as "type"):
- MetricCard
props: { "label": string, "value": string, "trend": string | null }
- BarChart
props: {
"title": string,
"description": string | null,
"data": [ { "label": string, "value": number }, ... ]
}
- PieChart
props: {
"title": string,
"description": string | null,
"data": [ { "label": string, "value": number }, ... ]
}
Rules:
1. Output only valid JSON. No markdown code fences. No text outside the object.
2. Every id referenced in root or any children array must be a key in elements.
3. For a multi-component dashboard, use a root MetricCard and list the charts in its children array.
4. Use realistic sales-domain values.
5. Never invent component types outside the three listed above.`;
@@ -0,0 +1,121 @@
/**
* Strands TypeScript showcase agent server.
*
* An Express app exposing AG-UI SSE endpoints, mirroring the Python sibling's
* `agent_server.py`: a single shared showcase agent at `/`, plus tool-free
* specialized agents on dedicated sub-paths (`/voice`, `/byoc-hashbrown`,
* `/byoc-json-render`). The Next.js CopilotKit runtime proxies here via the
* AG-UI protocol (HttpAgent), so per-demo differentiation lives on the
* frontend.
*
* Run with `node --import tsx server.ts` (tsx is a one-shot ESM loader, not a
* watcher) — see package.json.
*/
import express from "express";
import cors from "cors";
import { addStrandsExpressEndpoint, addPing } from "@ag-ui/aws-strands/server";
import type { StrandsAgent } from "@ag-ui/aws-strands";
import { strandsCvdiagMiddleware } from "./cvdiag-backend-strands.js";
import {
buildShowcaseAgent,
buildVoiceAgent,
buildByocHashbrownAgent,
buildByocJsonRenderAgent,
buildA2uiFixedSchemaAgent,
buildA2uiDynamicAgent,
buildA2uiRecoveryAgent,
} from "./agent";
/** Mount an agent at `path` and `path/` so trailing-slash proxies resolve. */
function mountAgent(
app: express.Express,
path: string,
agent: StrandsAgent,
): void {
// Mount the CVDIAG / header-forwarding middleware on the SAME path FIRST so
// Express runs it BEFORE the aws-strands POST handler. It seeds the
// header-forwarding ALS (so the outbound OpenAI call to aimock carries the
// inbound x-* incl. X-AIMock-Strict) and — when CVDIAG_BACKEND_EMITTER is on
// — emits the backend.* boundaries around the streamed response. It calls
// next() to fall through to the aws-strands handler, which then runs inside
// the ALS scope (AsyncLocalStorage propagates across the async generator
// iteration). @ag-ui/aws-strands@0.2.3 reads only req.body + accept and drops
// inbound x-*, so this re-introduces per-request forwarding without touching
// the third-party package.
const mw = strandsCvdiagMiddleware({
slug: "strands-typescript",
agentName: "strands_agent",
provider: "openai",
modelId: process.env.MODEL_ID ?? "gpt-4o",
});
app.post(path, mw);
addStrandsExpressEndpoint(app, agent, { path });
if (path !== "/") {
app.post(`${path}/`, mw);
addStrandsExpressEndpoint(app, agent, { path: `${path}/` });
}
}
async function main(): Promise<void> {
const app = express();
app.use(cors({ origin: true, credentials: true }));
// 25mb so the multimodal demo's base64 image/PDF uploads fit comfortably.
app.use(express.json({ limit: "25mb" }));
// Health + ping registered BEFORE the agent POST endpoints. They are GET
// routes so they never collide with the agents' POST handlers, but keeping
// them first makes the contract explicit. The Next.js runtime probes
// `${AGENT_URL}/health`.
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
addPing(app, "/ping");
const [
showcase,
voice,
hashbrown,
jsonRender,
a2uiFixed,
a2uiDynamic,
a2uiRecovery,
] = await Promise.all([
buildShowcaseAgent(),
buildVoiceAgent(),
buildByocHashbrownAgent(),
buildByocJsonRenderAgent(),
buildA2uiFixedSchemaAgent(),
buildA2uiDynamicAgent(),
buildA2uiRecoveryAgent(),
]);
mountAgent(app, "/voice", voice);
mountAgent(app, "/byoc-hashbrown", hashbrown);
mountAgent(app, "/byoc-json-render", jsonRender);
// Fixed-schema A2UI: the dedicated agent wires its OWN backend tool
// (`display_flight`) returning an a2ui_operations envelope; the runtime
// A2UIMiddleware paints it directly. No generate_a2ui injection.
mountAgent(app, "/a2ui-fixed-schema", a2uiFixed);
// Dynamic-schema A2UI: the dedicated agent wires NO tool — the runtime's
// `injectA2UITool: true` makes the adapter auto-inject `generate_a2ui` and
// GENERATE the surface, stamped with the page's catalog id.
mountAgent(app, "/declarative-gen-ui", a2uiDynamic);
// Error-recovery A2UI: same auto-inject setup; aimock fixtures force the
// inner render to heal or exhaust so the adapter's recovery loop is visible.
mountAgent(app, "/a2ui-recovery", a2uiRecovery);
// Mount the shared agent LAST at the root so the sub-path POST routes are
// matched first by Express's route table.
mountAgent(app, "/", showcase);
const port = Number(process.env.PORT ?? 8000);
const host = process.env.HOST ?? "0.0.0.0";
app.listen(port, host, () => {
console.log(`[agent] Strands TS server listening on ${host}:${port}`);
});
}
main().catch((err) => {
console.error("[agent] Fatal:", err);
process.exit(1);
});
@@ -0,0 +1,277 @@
/**
* Shared-state plumbing for the Strands showcase agent.
*
* Mirrors the Python sibling's `build_state_prompt` + `*_state_from_args` /
* `*_state_from_result` hooks: the UI owns certain state slots (preferences,
* notes, steps, sales todos, delegations) and the adapter emits
* `StateSnapshotEvent`s the moment a tool fires so the corresponding panel
* re-renders without waiting for the text response to stream.
*/
import type {
ToolCallContext,
ToolResultContext,
StatePayload,
} from "@ag-ui/aws-strands";
import type { RunAgentInput } from "@ag-ui/core";
import { manageSalesTodosImpl } from "./lib/tool-impls";
/** Marker returned by a sub-agent tool body when its LLM call failed. */
export const SUBAGENT_FAILURE_MARKER = "__SUBAGENT_FAILED__:";
/** Parse a tool's input (string JSON or already-parsed object). */
function parseToolInput(raw: unknown): unknown {
if (typeof raw === "string") {
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
return raw;
}
// ---- stateContextBuilder -------------------------------------------------
function formatPreferencesBlock(prefs: unknown): string | null {
if (!prefs || typeof prefs !== "object") return null;
const p = prefs as Record<string, unknown>;
const lines: string[] = [];
if (p.name) lines.push(`- Name: ${p.name}`);
if (p.tone) lines.push(`- Preferred tone: ${p.tone}`);
if (p.language) lines.push(`- Preferred language: ${p.language}`);
const interests = p.interests;
if (Array.isArray(interests) && interests.length > 0) {
lines.push(`- Interests: ${interests.map((i) => String(i)).join(", ")}`);
}
if (lines.length === 0) return null;
return (
"The user has shared these preferences with you:\n" +
lines.join("\n") +
"\nTailor every response to these preferences. Address the user by name when appropriate."
);
}
/**
* Format the AG-UI `context` array into a prompt block.
*
* `RunAgentInput.context` is populated by the frontend's `useAgentContext`
* (readonly-state-agent-context), by `openGenerativeUI.designSkill`, and by
* sandbox-function descriptors (open-gen-ui / advanced). The Strands adapter
* does NOT surface `context` to the model on its own, so without lifting it
* here the agent never sees readonly context ("Who am I?") nor the
* open-gen-ui design skill / "call generateSandboxedUi" guidance. Mirrors
* langgraph's lift-context-into-prompt pattern; the Python sibling does the
* same in `build_state_prompt`.
*/
function formatContextBlock(context: unknown): string | null {
if (!Array.isArray(context) || context.length === 0) return null;
const lines: string[] = [];
for (const item of context) {
if (!item || typeof item !== "object") continue;
const c = item as Record<string, unknown>;
if (c.description == null || c.value == null) continue;
lines.push(`- ${String(c.description)}: ${String(c.value)}`);
}
if (lines.length === 0) return null;
return (
"Context for this conversation (treat as authoritative — use it to answer questions about the user and follow any instructions it contains):\n" +
lines.join("\n")
);
}
/**
* Inject UI-owned shared-state slots and AG-UI context into the outgoing
* prompt. Degrades to the original prompt when no relevant slot is present.
*/
export function buildStatePrompt(
inputData: RunAgentInput,
prompt: string,
): string {
const state = (inputData.state ?? {}) as Record<string, unknown>;
const blocks: string[] = [];
if (state && typeof state === "object") {
const prefsBlock = formatPreferencesBlock(state.preferences);
if (prefsBlock) blocks.push(prefsBlock);
if ("todos" in state) {
blocks.push(
`Current sales pipeline:\n${JSON.stringify(state.todos, null, 2)}`,
);
}
}
const contextBlock = formatContextBlock(inputData.context);
if (contextBlock) blocks.push(contextBlock);
if (blocks.length === 0) return prompt;
return `${blocks.join("\n\n")}\n\nUser request: ${prompt}`;
}
// ---- state-from-args hooks -----------------------------------------------
/** manage_sales_todos → { todos } */
export async function salesStateFromArgs(
ctx: ToolCallContext,
): Promise<StatePayload | null> {
const input = parseToolInput(ctx.toolInput);
let todos: unknown;
if (input && typeof input === "object" && !Array.isArray(input)) {
todos = (input as Record<string, unknown>).todos ?? input;
} else if (Array.isArray(input)) {
todos = input;
} else {
return null;
}
if (!Array.isArray(todos)) return null;
return { todos: manageSalesTodosImpl(todos as never[]) };
}
/** set_notes → { notes } */
export async function notesStateFromArgs(
ctx: ToolCallContext,
): Promise<StatePayload | null> {
const input = parseToolInput(ctx.toolInput);
let notes: unknown;
if (input && typeof input === "object" && !Array.isArray(input)) {
notes = (input as Record<string, unknown>).notes;
} else if (Array.isArray(input)) {
notes = input;
}
if (!Array.isArray(notes)) return null;
return { notes: notes.map((n) => String(n)) };
}
/** set_steps → { steps } (gen-ui-agent live progress card) */
export async function stepsStateFromArgs(
ctx: ToolCallContext,
): Promise<StatePayload | null> {
const input = parseToolInput(ctx.toolInput);
let steps: unknown;
if (input && typeof input === "object" && !Array.isArray(input)) {
steps = (input as Record<string, unknown>).steps;
} else if (Array.isArray(input)) {
steps = input;
}
if (!Array.isArray(steps)) return null;
const cleaned = steps
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
.map((s) => ({
id: String(s.id ?? ""),
title: String(s.title ?? ""),
status: String(s.status ?? "pending"),
}));
return { steps: cleaned };
}
/** write_document → { document } (shared-state-streaming live document).
*
* Mirrors langgraph-python's StateStreamingMiddleware target: the full
* document string lands in `state.document`. Strands updates state from the
* complete tool args (not per-token), which the d5 probe tolerates — it only
* asserts the document grew substantively after settle, not mid-stream
* chunking. */
export async function documentStateFromArgs(
ctx: ToolCallContext,
): Promise<StatePayload | null> {
const input = parseToolInput(ctx.toolInput);
let document: unknown;
if (input && typeof input === "object" && !Array.isArray(input)) {
document = (input as Record<string, unknown>).document;
} else if (typeof input === "string") {
document = input;
}
if (typeof document !== "string" || document.length === 0) return null;
return { document };
}
// ---- sub-agents (delegation log) -----------------------------------------
interface Delegation {
id: string;
sub_agent: string;
task: string;
status: "completed" | "failed";
result: string;
}
// Per-thread scratchpad of delegations, seeded from inbound state so a
// multi-turn conversation appends rather than overwrites.
const delegationsByThread = new Map<string, Delegation[]>();
function seedDelegations(threadId: string, state: unknown): Delegation[] {
const existing = delegationsByThread.get(threadId);
if (existing) return existing;
let seeded: Delegation[] = [];
if (state && typeof state === "object") {
const d = (state as Record<string, unknown>).delegations;
if (Array.isArray(d)) {
seeded = d.filter((x): x is Delegation => !!x && typeof x === "object");
}
}
delegationsByThread.set(threadId, seeded);
return seeded;
}
function flattenResult(resultData: unknown): string {
if (resultData == null) return "";
if (typeof resultData === "string") return resultData;
if (Array.isArray(resultData)) {
const parts: string[] = [];
for (const item of resultData) {
if (item && typeof item === "object" && "text" in item) {
const t = (item as { text?: unknown }).text;
if (typeof t === "string") parts.push(t);
} else if (typeof item === "string") {
parts.push(item);
}
}
if (parts.length) return parts.join("\n");
}
if (typeof resultData === "object" && "text" in resultData) {
const t = (resultData as { text?: unknown }).text;
if (typeof t === "string") return t;
}
return JSON.stringify(resultData);
}
/**
* Factory for a `stateFromResult` hook bound to a sub-agent name. On each
* delegation it appends a Delegation entry to the per-thread scratchpad and
* returns the full updated list so the adapter emits a `StateSnapshotEvent`.
*/
export function makeSubagentStateFromResult(subAgentName: string) {
return async (ctx: ToolResultContext): Promise<StatePayload | null> => {
const threadId = ctx.inputData.threadId || "default";
const existing = seedDelegations(threadId, ctx.inputData.state);
const input = parseToolInput(ctx.toolInput);
let task = "";
if (input && typeof input === "object" && !Array.isArray(input)) {
task = String((input as Record<string, unknown>).task ?? "");
}
const resultText = flattenResult(ctx.resultData);
let status: Delegation["status"];
let displayResult: string;
if (resultText.startsWith(SUBAGENT_FAILURE_MARKER)) {
status = "failed";
const failureClass =
resultText.slice(SUBAGENT_FAILURE_MARKER.length).trim() || "Error";
displayResult = `Sub-agent call failed (${failureClass}).`;
} else {
status = "completed";
displayResult = resultText;
}
const entry: Delegation = {
id: crypto.randomUUID(),
sub_agent: subAgentName,
task,
status,
result: displayResult,
};
const updated = [...existing, entry];
delegationsByThread.set(threadId, updated);
return { delegations: updated.map((d) => ({ ...d })) };
};
}
@@ -0,0 +1,81 @@
/**
* Regression test for sub-agent LLM-path header forwarding.
*
* The sub-agent `openaiClient()` in tools.ts must wire `fetch: forwardingFetch`
* (mirroring model-factory.ts) so inbound `X-AIMock-Strict` / `x-test-id` /
* `x-diag-*` headers — seeded into the AsyncLocalStorage scope by the strands
* cvdiag middleware via `withForwardedHeaders` — flow onto the sub-agent's
* outbound OpenAI request. Without it, those headers are DROPPED on the
* sub-agent path while the main agent's model carries them, so a probe's strict
* verification silently falls through on the delegated (research/writing/
* critique) calls.
*
* RED (pre-fix): client built with `defaultHeaders` only, no `fetch` →
* outbound request lacks `X-AIMock-Strict`.
* GREEN (post-fix): `fetch: forwardingFetch` wired → header present.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withForwardedHeaders } from "./header-forwarding.js";
import { openaiClient } from "./tools.js";
import type { Request } from "express";
describe("sub-agent openaiClient header forwarding", () => {
const origKey = process.env.OPENAI_API_KEY;
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
process.env.OPENAI_API_KEY = "test-key";
// Spy on global fetch so the OpenAI client's forwardingFetch ultimately
// calls into it; capture the outbound headers without hitting the network.
fetchSpy = vi.fn(async () => {
return new Response(
JSON.stringify({
id: "x",
object: "chat.completion",
created: 0,
model: "gpt-4o-mini",
choices: [
{
index: 0,
message: { role: "assistant", content: "ok" },
finish_reason: "stop",
},
],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
if (origKey === undefined) delete process.env.OPENAI_API_KEY;
else process.env.OPENAI_API_KEY = origKey;
});
/** Minimal Express-like request carrying the strict probe header. */
function reqWith(headers: Record<string, string>): Request {
return { headers } as unknown as Request;
}
it("forwards inbound X-AIMock-Strict onto the sub-agent outbound call", async () => {
const client = openaiClient();
await withForwardedHeaders(
reqWith({ "x-aimock-strict": "1" }),
async () => {
await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: "hi" }],
});
},
);
expect(fetchSpy).toHaveBeenCalled();
const init = fetchSpy.mock.calls[0]?.[1] as RequestInit | undefined;
const headers = new Headers(init?.headers);
expect(headers.get("x-aimock-strict")).toBe("1");
});
});
@@ -0,0 +1,269 @@
/**
* Strands `tool()` definitions for the shared showcase agent.
*
* Mirrors the Python sibling's tool set (`src/agents/agent.py`) minus the
* A2UI `generate_a2ui` tool. Frontend-only tools (theme toggles, HITL
* components) are NOT defined here — the @ag-ui/aws-strands adapter
* auto-registers them as proxy tools from `RunAgentInput.tools`, so the LLM
* sees them and the browser executes them.
*/
import { tool } from "@strands-agents/sdk";
import OpenAI from "openai";
import { z } from "zod";
import { AIMOCK_CONTEXT } from "./model-factory";
import { forwardingFetch } from "./header-forwarding.js";
import { SUBAGENT_FAILURE_MARKER } from "./state";
import {
getWeatherImpl,
manageSalesTodosImpl,
queryDataImpl,
rollDiceImpl,
scheduleMeetingImpl,
searchFlightsImpl,
} from "./lib/tool-impls";
import type { Flight } from "./lib/tool-impls";
export const getWeather = tool({
name: "get_weather",
description: "Get current weather for a location.",
inputSchema: z.object({
location: z.string().describe("The location to get weather for."),
}),
callback: ({ location }) => JSON.stringify(getWeatherImpl(location)),
});
export const queryData = tool({
name: "query_data",
description:
"Query the financial database for chart data. Always call before showing a chart or graph.",
inputSchema: z.object({
query: z.string().describe("Natural language query for financial data."),
}),
callback: ({ query }) => JSON.stringify(queryDataImpl(query)),
});
export const manageSalesTodos = tool({
name: "manage_sales_todos",
description:
"Manage the sales pipeline by replacing the entire list of todos. ALWAYS provide the entire list, not just new items.",
inputSchema: z.object({
todos: z
.array(z.record(z.string(), z.unknown()))
.describe("The complete updated list of sales todos."),
}),
callback: ({ todos }) => {
const result = manageSalesTodosImpl(todos as never[]);
return `Sales todos updated. Tracking ${result.length} item(s).`;
},
});
export const getSalesTodos = tool({
name: "get_sales_todos",
description: "Get the current sales pipeline todos.",
inputSchema: z.object({}),
callback: () => "Check the sales pipeline provided in the context.",
});
export const scheduleMeeting = tool({
name: "schedule_meeting",
description:
"Schedule a meeting with user approval. The user picks a time in the UI.",
inputSchema: z.object({
reason: z.string().describe("Reason for the meeting."),
}),
callback: ({ reason }) => JSON.stringify(scheduleMeetingImpl(reason)),
});
export const searchFlights = tool({
name: "search_flights",
description:
'Search for flights and display the results as rich cards. Return exactly 2 flights. Each flight must have: airline, airlineLogo, flightNumber, origin, destination, date (short readable format like "Tue, Mar 18" -- use near-future dates), departureTime, arrivalTime, duration (e.g. "4h 25m"), status (e.g. "On Time" or "Delayed"), statusColor (hex color for status dot), price (e.g. "$289"), and currency (e.g. "USD"). For airlineLogo use the Google favicon API: https://www.google.com/s2/favicons?domain={airline_domain}&sz=128',
inputSchema: z.object({
flights: z
.array(z.record(z.string(), z.unknown()))
.describe("List of flight objects."),
}),
callback: ({ flights }) =>
JSON.stringify(searchFlightsImpl(flights as unknown as Flight[])),
});
export const rollDice = tool({
name: "roll_dice",
description:
"Roll a die with the given number of sides and return the result. Use for any dice-rolling request (e.g. 'roll a d20' → sides=20).",
inputSchema: z.object({
sides: z
.number()
.int()
.min(2)
.max(1000)
.describe("Number of sides (e.g. 20 for a d20)."),
}),
callback: ({ sides }) => JSON.stringify(rollDiceImpl(sides)),
});
export const setThemeColor = tool({
name: "set_theme_color",
description:
"Change the theme color of the UI. This is rendered on the frontend.",
inputSchema: z.object({
theme_color: z.string().describe("The color to set as theme."),
}),
callback: ({ theme_color }) => `Theme color set to ${theme_color}.`,
});
export const setNotes = tool({
name: "set_notes",
description:
"Replace the notes array in shared state with the full updated list. Use whenever the user asks you to remember something. ALWAYS pass the FULL notes list (existing + new), not a diff. Keep each note short (< 120 chars).",
inputSchema: z.object({
notes: z
.array(z.string())
.describe("The complete updated list of short note strings."),
}),
callback: ({ notes }) => `Notes updated. Tracking ${notes.length} note(s).`,
});
export const setSteps = tool({
name: "set_steps",
description:
'Publish the current plan and step statuses. Call every time a step transitions (including the first enumeration). ALWAYS pass the COMPLETE list of steps. Each step is { id: string, title: string, status: "pending" | "in_progress" | "completed" }.',
inputSchema: z.object({
steps: z
.array(
z.object({
id: z.string(),
title: z.string(),
status: z.string(),
}),
)
.describe("The complete list of steps with current statuses."),
}),
callback: ({ steps }) => `Published ${steps.length} step(s).`,
});
export const writeDocument = tool({
name: "write_document",
description:
"Write a document for the user. Call this whenever the user asks you to write, draft, or revise any piece of text (a poem, email, essay, summary, etc.). Pass the FULL content as a single string in the `document` argument — the document lives in shared state and the UI renders it live; never paste it into a chat message.",
inputSchema: z.object({
document: z
.string()
.describe("The full document content as a single string."),
}),
callback: () => "Document written to shared state.",
});
// ---- Sub-agents ----------------------------------------------------------
const SUBAGENT_SYSTEM_PROMPTS: Record<string, string> = {
research_agent:
"You are a research sub-agent. Given a topic, produce a concise bulleted list of 3-5 key facts. No preamble, no closing.",
writing_agent:
"You are a writing sub-agent. Given a brief and optional source facts, produce a polished 1-paragraph draft. Be clear and concrete. No preamble.",
critique_agent:
"You are an editorial critique sub-agent. Given a draft, give 2-3 crisp, actionable critiques. No preamble.",
};
const SUBAGENT_EMPTY_RESULT = "(sub-agent returned no content)";
let _openaiClient: OpenAI | null = null;
export function openaiClient(): OpenAI {
if (!_openaiClient) {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error("OPENAI_API_KEY must be set for sub-agent delegation.");
}
_openaiClient = new OpenAI({
apiKey,
...(process.env.OPENAI_BASE_URL
? { baseURL: process.env.OPENAI_BASE_URL }
: {}),
// Match the shared agent so sub-agent calls hit the right aimock fixtures.
defaultHeaders: { "x-aimock-context": AIMOCK_CONTEXT },
// Per-request inbound x-* forwarding (incl. X-AIMock-Strict / x-test-id /
// x-diag-*), mirroring model-factory.ts. The sub-agent client is built
// ONCE (memoized), but forwardingFetch reads an AsyncLocalStorage
// snapshot per outbound call (seeded by the Express cvdiag/forwarding
// middleware around agent.run()), so per-request headers flow correctly.
// It never clobbers the static x-aimock-context above, and is
// byte-identical to a plain fetch when no x-* are in scope (demo traffic
// unaffected).
fetch: forwardingFetch,
});
}
return _openaiClient;
}
/**
* Run a single-shot completion as a sub-agent. Returns the failure marker
* (caught in `state.ts`) on transport/API errors rather than throwing, so a
* delegation failure surfaces as a "failed" log row instead of a 500.
*/
async function runSubagent(name: string, task: string): Promise<string> {
const systemPrompt = SUBAGENT_SYSTEM_PROMPTS[name];
try {
const response = await openaiClient().chat.completions.create({
model: process.env.SUBAGENT_MODEL_ID ?? "gpt-4o-mini",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: task },
],
});
const content = response.choices[0]?.message?.content ?? "";
const text = content.trim();
return text || SUBAGENT_EMPTY_RESULT;
} catch (err) {
const cls = err instanceof Error ? err.constructor.name : "Error";
return `${SUBAGENT_FAILURE_MARKER}${cls}`;
}
}
export const researchAgent = tool({
name: "research_agent",
description:
"Delegate a research task to the research sub-agent. Use for gathering facts, background, definitions, statistics. Returns a bulleted list of key facts.",
inputSchema: z.object({
task: z.string().describe("The research brief to hand off."),
}),
callback: ({ task }) => runSubagent("research_agent", task),
});
export const writingAgent = tool({
name: "writing_agent",
description:
"Delegate a drafting task to the writing sub-agent. Use for producing a polished paragraph, draft, or summary. Pass relevant facts inside `task`.",
inputSchema: z.object({
task: z.string().describe("The writing brief to hand off."),
}),
callback: ({ task }) => runSubagent("writing_agent", task),
});
export const critiqueAgent = tool({
name: "critique_agent",
description:
"Delegate a critique task to the critique sub-agent. Use for reviewing a draft and suggesting concrete improvements.",
inputSchema: z.object({
task: z.string().describe("The draft to critique."),
}),
callback: ({ task }) => runSubagent("critique_agent", task),
});
/** Full tool set for the shared showcase agent. */
export const SHOWCASE_TOOLS = [
getSalesTodos,
manageSalesTodos,
getWeather,
queryData,
rollDice,
scheduleMeeting,
searchFlights,
setThemeColor,
setNotes,
setSteps,
writeDocument,
researchAgent,
writingAgent,
critiqueAgent,
];
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmit": true,
"types": ["node"]
},
"include": ["*.ts", "lib/**/*.ts", "../cvdiag/**/*.ts"],
"exclude": ["node_modules", "**/*.test.ts"]
}
@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// Co-located agent-process unit tests. The integration's broader suite is
// Playwright e2e (`test:e2e` at the integration root); this config scopes
// vitest to the agent package's own unit tests, which resolve the agent's
// own node_modules (openai / @strands-agents) rather than the Next app's.
include: ["*.test.ts", "lib/**/*.test.ts"],
environment: "node",
},
});
@@ -0,0 +1,58 @@
// Dedicated runtime for the Declarative Generative UI (A2UI fixed-schema) demo.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
// Dedicated backend agent mounted at /a2ui-fixed-schema (see
// src/agent/server.ts). It owns the `display_flight` tool which emits its
// own a2ui_operations envelope; the runtime A2UIMiddleware paints it. No
// `a2ui: { injectA2UITool }` flag is needed — the envelope mechanism does
// not require runtime tool injection.
return new HttpAgent({ url: `${AGENT_URL}/a2ui-fixed-schema/` });
}
const a2uiFixedAgent = createAgent();
const agents = {
"a2ui-fixed-schema": a2uiFixedAgent,
default: a2uiFixedAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-a2ui-fixed-schema",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
// Enable the A2UIMiddleware so it detects the `a2ui_operations`
// envelope the dedicated `a2ui_fixed_schema` backend agent's
// `display_flight` tool returns and converts it into A2UI activity
// events the page's catalog renders. `injectA2UITool: false` because
// the agent emits the envelope itself (no generate_a2ui injection).
// Mirrors the beautiful-chat route. Pin the catalog the page registers
// so the middleware doesn't fall back to the unregistered basic catalog.
a2ui: {
injectA2UITool: false,
defaultCatalogId: "copilotkit://flight-fixed-catalog",
},
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,54 @@
// Dedicated runtime for the A2UI Error Recovery demo.
// Scoped so a2ui options for this cell stay isolated from the shared route.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
// Dedicated backend agent mounted at /a2ui-recovery (see src/agent/server.ts).
// It wires NO generate_a2ui tool — the catalog the page passes to the provider
// auto-enables A2UI tool injection, so the Strands adapter auto-injects it,
// drives the render_a2ui planner, and runs the toolkit validate->retry
// recovery loop on that auto-inject path. Trailing slash so the
// sub-application's root route resolves.
return new HttpAgent({ url: `${AGENT_URL}/a2ui-recovery/` });
}
const a2uiAgent = createAgent();
const agents = {
"a2ui-recovery": a2uiAgent,
default: a2uiAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-a2ui-recovery",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
// No runtime `a2ui` config: the page passes a catalog to the provider
// (`<CopilotKit a2ui={{ catalog }}>`), which auto-enables A2UI and
// defaults tool injection on (CopilotKit >= 1.61.2, PR #5611). The
// Strands adapter then auto-injects `generate_a2ui` and runs the
// recovery loop the A2UIMiddleware renders.
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,47 @@
// Dedicated runtime for the Agent Config Object demo.
//
// The page uses <CopilotKitProvider properties={...}> to forward a typed
// config object (tone / expertise / responseLength) to the agent. Scoped to
// its own endpoint so the Playwright spec can assert propagation against a
// single URL.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
const agentConfigAgent = createAgent();
const agents = {
"agent-config-demo": agentConfigAgent,
default: agentConfigAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-agent-config",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,58 @@
// Dedicated runtime for the /demos/auth cell.
//
// Demonstrates framework-native request authentication via the V2 runtime's
// `onRequest` hook. A static `Authorization: Bearer <DEMO_TOKEN>` header is
// required; mismatch throws a 401 before the request reaches the agent.
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
const authDemoAgent = createAgent();
const runtime = new CopilotRuntime({
agents: {
// @ts-ignore -- HttpAgent is structurally compatible with AbstractAgent but misses the private `_debug*` fields in the published .d.ts.
"auth-demo": authDemoAgent,
// @ts-ignore
default: authDemoAgent,
},
});
const BASE_PATH = "/api/copilotkit-auth";
const handler = createCopilotRuntimeHandler({
runtime,
basePath: BASE_PATH,
hooks: {
onRequest: ({ request }) => {
const authHeader = request.headers.get("authorization");
if (authHeader !== DEMO_AUTH_HEADER) {
throw new Response(
JSON.stringify({
error: "unauthorized",
message:
"Missing or invalid Authorization header. Click Authenticate above to send messages.",
}),
{
status: 401,
headers: { "content-type": "application/json" },
},
);
}
},
},
});
export const POST = (req: NextRequest) => handler(req);
export const GET = (req: NextRequest) => handler(req);
@@ -0,0 +1,78 @@
// Dedicated runtime for the Beautiful Chat flagship showcase cell (Strands).
//
// Beautiful Chat exercises A2UI (dynamic + fixed schema) and Open
// Generative UI. The shared Strands backend (agent_server.py) hosts a
// single Strands Agent instance on "/", so the cell routes there; the
// flagship behavior comes from the runtime flags below plus the frontend's
// per-cell registrations.
//
// Isolated on its own endpoint (mirroring beautiful-chat in the canonical)
// because the `openGenerativeUI` / `a2ui` runtime flags set global state on
// the probe response that would otherwise leak into the other cells sharing
// the default `/api/copilotkit` runtime.
//
// Mirrors showcase/integrations/pydantic-ai/src/app/api/copilotkit-beautiful-chat/route.ts.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
// The beautiful-chat page resolves <CopilotKit agent="beautiful-chat">
// here; internal components (headless-chat, example-canvas) also call
// `useAgent()` with no args, which defaults to agentId "default". Alias
// default to the same backend so those hooks resolve.
const beautifulChatAgent = createAgent();
const agents = {
"beautiful-chat": beautifulChatAgent,
default: beautifulChatAgent,
};
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
openGenerativeUI: true,
a2ui: {
// The targeted Strands agent ("/") already registers the `generate_a2ui`
// tool itself (build_showcase_agent in src/agents/agent.py), so the
// runtime must NOT inject a second copy — that would double-bind the
// render tool. Matches pydantic-ai's beautiful-chat (this file's mirror
// source).
injectA2UITool: false,
// Models follow the tool-usage guide and omit `catalogId`, and the
// middleware then falls back to the unregistered spec basic catalog
// ("Catalog not found" render error). Pin the catalog the page registers.
defaultCatalogId: "copilotkit://app-dashboard-catalog",
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-beautiful-chat",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
console.error(
`[copilotkit-beautiful-chat/route] ERROR: ${e.message}`,
e.stack,
);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
@@ -0,0 +1,53 @@
// Dedicated runtime for the Declarative Generative UI (A2UI dynamic) demo.
// Scoped so a2ui options for this cell stay isolated from the shared route.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
// Dedicated backend agent mounted at /declarative-gen-ui (see
// src/agent/server.ts). It wires NO generate_a2ui tool — the catalog the page
// passes to the provider auto-enables A2UI tool injection, so the Strands
// adapter auto-injects it and GENERATEs the surface layout. Trailing slash so
// the sub-application's root route resolves.
return new HttpAgent({ url: `${AGENT_URL}/declarative-gen-ui/` });
}
const a2uiAgent = createAgent();
const agents = {
"declarative-gen-ui": a2uiAgent,
default: a2uiAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-gen-ui",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
// No runtime `a2ui` config: the page passes a catalog to the provider
// (`<CopilotKit a2ui={{ catalog }}>`), which auto-enables A2UI and
// defaults tool injection on (CopilotKit >= 1.61.2, PR #5611). The
// Strands adapter then auto-injects `generate_a2ui` from the forwarded
// flag and drives the render planner the A2UIMiddleware paints.
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,56 @@
// Dedicated runtime for the declarative-hashbrown demo (Strands).
//
// The declarative-hashbrown demo needs the LLM to emit a strict hashbrown
// JSON envelope (see src/agent/agent.ts (buildByocHashbrownAgent) for the canonical prompt).
// The shared Strands agent at "/" cannot produce that envelope, so the
// backend mounts a dedicated, prompt-specialized agent at `/byoc-hashbrown/`
// (see src/agent/server.ts) and this route proxies to it.
//
// The demo folder + route + agent slug were renamed from `byoc-hashbrown` to
// the canonical `declarative-hashbrown` surface; the page mounts
// <CopilotKit agent="declarative-hashbrown-demo">.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/byoc-hashbrown/` });
}
const declarativeHashbrownAgent = createAgent();
const agents = {
"declarative-hashbrown-demo": declarativeHashbrownAgent,
default: declarativeHashbrownAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-hashbrown",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-expect-error -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
console.error(
`[copilotkit-declarative-hashbrown/route] ERROR: ${e.message}`,
e.stack,
);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
@@ -0,0 +1,56 @@
// Dedicated runtime for the declarative-json-render demo (Strands).
//
// The demo page renders the agent's JSON output into a frontend-owned
// component catalog via @json-render/react. The backend mounts a dedicated,
// prompt-specialized agent at `/byoc-json-render/` whose system prompt
// (src/agent/agent.ts (buildByocJsonRenderAgent)) instructs the LLM to emit the
// `@json-render/react` flat-spec envelope (`{ root, elements }`); this route
// proxies to that endpoint rather than the generic "/" agent. The demo folder
// + route surface were renamed from `byoc-json-render` to the canonical
// `declarative-json-render`; the agent ID retains its legacy
// `byoc_json_render` name.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/byoc-json-render/` });
}
const byocJsonRenderAgent = createAgent();
const agents = {
byoc_json_render: byocJsonRenderAgent,
default: byocJsonRenderAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-json-render",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-expect-error -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
console.error(
`[copilotkit-declarative-json-render/route] ERROR: ${e.message}`,
e.stack,
);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
@@ -0,0 +1,85 @@
// Dedicated runtime for the MCP Apps demo.
//
// The runtime's `mcpApps` config auto-applies the MCP Apps middleware to the
// agent: when the agent calls a tool backed by an MCP UI resource, the
// middleware fetches the resource and emits the activity event that the
// built-in `MCPAppsActivityRenderer` (registered by CopilotKit internally)
// renders in the chat as a sandboxed iframe.
//
// The Strands shared backend exposes a single Strands Agent. The MCP Apps
// middleware works at the runtime layer: it injects MCP-server-discovered
// tools into the request, so the agent does not need any bespoke tool
// registrations to drive the demo. The agent simply calls whatever MCP tool
// the runtime advertises.
//
// Reference (langgraph-python sibling):
// showcase/integrations/langgraph-python/src/app/api/copilotkit-mcp-apps/route.ts
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
const mcpAppsAgent = createAgent();
const agents = {
// headless-complete shares this runtime (its page wires
// runtimeUrl="/api/copilotkit-mcp-apps") but is backed by the single
// shared Strands Agent at "/" — the same backend the main route
// registers it against.
"headless-complete": createAgent(),
"mcp-apps": mcpAppsAgent,
default: mcpAppsAgent,
};
// @region[runtime-mcpapps-config]
// The `mcpApps.servers` config is all you need server-side. The runtime
// auto-applies the MCP Apps middleware to every registered agent: on each
// MCP tool call it fetches the associated UI resource and emits an
// `activity` event that the built-in `MCPAppsActivityRenderer` renders
// inline in the chat.
const runtime = new CopilotRuntime({
// @ts-expect-error -- see main route.ts; published CopilotRuntime's `agents`
// type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects
// plain Records. Fixed in source, pending release.
agents,
mcpApps: {
servers: [
{
type: "http",
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
// Always pin a stable `serverId`. Without it CopilotKit hashes the
// URL, and a URL change silently breaks restoration of persisted
// MCP Apps in prior conversation threads.
serverId: "excalidraw",
},
],
},
});
// @endregion[runtime-mcpapps-config]
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-mcp-apps",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,48 @@
// Dedicated runtime for the Multimodal Attachments demo.
//
// Mirrors the LangGraph-Python shape: the multimodal cell gets its own
// runtime so vision-capable model settings stay scoped to that one cell.
// In the Strands showcase the backend is a single shared Strands agent
// (served by src/agent/server.ts on port 8000); this route just registers the
// demo's slug against the same HttpAgent proxy as the main route.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
const multimodalAgent = createAgent();
const agents = {
"multimodal-demo": multimodalAgent,
default: multimodalAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-multimodal",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,57 @@
// Dedicated runtime for the Open Generative UI demos.
//
// Isolated here because the `openGenerativeUI` runtime flag sets
// `openGenerativeUIEnabled: true` globally on the probe response, which
// causes the CopilotKit provider's setTools effect to wipe per-demo
// `useFrontendTool`/`useComponent` registrations in the default runtime.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
const openGenUiAgent = createAgent();
const openGenUiAdvancedAgent = createAgent();
const agents = {
"open-gen-ui": openGenUiAgent,
"open-gen-ui-advanced": openGenUiAdvancedAgent,
default: openGenUiAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-ogui",
serviceAdapter: new ExperimentalEmptyAdapter(),
// @region[minimal-runtime-flag]
// @region[advanced-runtime-config]
runtime: new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents,
openGenerativeUI: {
agents: ["open-gen-ui", "open-gen-ui-advanced"],
},
}),
// @endregion[advanced-runtime-config]
// @endregion[minimal-runtime-flag]
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,83 @@
// Dedicated runtime for the /demos/voice cell (Strands).
//
// Goals
// -----
// 1. Advertise `audioFileTranscriptionEnabled: true` on `/info` so the chat
// composer renders the mic button.
// 2. Handle `POST /transcribe` by invoking an OpenAI-backed
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`).
// 3. Return a deterministic 4xx when `OPENAI_API_KEY` is not configured.
//
// Wires the V2 `CopilotRuntime` directly because the V1 wrapper drops the
// `transcriptionService` option. V2 URL-routes on `/info`, `/agent/:id/run`,
// `/transcribe`, etc., so the route lives at `[[...slug]]/route.ts`.
// @region[voice-runtime]
// @region[transcription-service-guard]
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
TranscriptionService,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import type { TranscribeFileOptions } from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
import OpenAI from "openai";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// Point at the tool-free /voice endpoint so aimock returns a direct text
// response instead of a tool call that the agent can't summarize.
const voiceDemoAgent = new HttpAgent({ url: `${AGENT_URL}/voice/` });
class GuardedOpenAITranscriptionService extends TranscriptionService {
private delegate: TranscriptionServiceOpenAI | null;
constructor() {
super();
const apiKey = process.env.OPENAI_API_KEY;
this.delegate = apiKey
? new TranscriptionServiceOpenAI({ openai: new OpenAI({ apiKey }) })
: null;
}
async transcribeFile(options: TranscribeFileOptions): Promise<string> {
if (!this.delegate) {
throw new Error(
"OPENAI_API_KEY not configured for this deployment (api key missing). " +
"Set OPENAI_API_KEY to enable voice transcription.",
);
}
return this.delegate.transcribeFile(options);
}
}
// @endregion[transcription-service-guard]
let cachedHandler: ((req: Request) => Promise<Response>) | null = null;
function getHandler(): (req: Request) => Promise<Response> {
if (cachedHandler) return cachedHandler;
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
// source, pending release.
agents: {
"voice-demo": voiceDemoAgent,
default: voiceDemoAgent,
},
transcriptionService: new GuardedOpenAITranscriptionService(),
});
cachedHandler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-voice",
});
return cachedHandler;
}
export const POST = (req: NextRequest) => getHandler()(req);
export const GET = (req: NextRequest) => getHandler()(req);
export const PUT = (req: NextRequest) => getHandler()(req);
export const DELETE = (req: NextRequest) => getHandler()(req);
// @endregion[voice-runtime]
@@ -0,0 +1,198 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import type { AbstractAgent, HttpAgentConfig } from "@ag-ui/client";
import { HttpAgent } from "@ag-ui/client";
import { AsyncLocalStorage } from "node:async_hooks";
// The agent backend runs as a separate process on port 8000.
// This runtime proxies CopilotKit requests to it via AG-UI protocol.
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// Per-request request/response logging is gated behind this flag (default off).
// Under d6 probe fan-out, unconditional per-request logs flooded Railway's
// 500-logs/sec cap and killed the replica ("Messages dropped" → container stop).
// Set SHOWCASE_ROUTE_DEBUG=1 to re-enable verbose per-request tracing locally.
const ROUTE_DEBUG =
process.env.SHOWCASE_ROUTE_DEBUG === "1" ||
process.env.SHOWCASE_ROUTE_DEBUG === "true";
console.log("[copilotkit/route] Initializing CopilotKit runtime");
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
// Per-request inbound x-* forwarding over the HttpAgent proxy hop. The Next
// route is a bare proxy; the model call happens in the Express agent (:8000).
// @ag-ui/client's HttpAgent `headers` are STATIC (construction-time), so the
// per-request seam is its `fetch` option: a custom fetch that reads an
// AsyncLocalStorage snapshot (seeded by the POST handler below) and injects the
// inbound x-* (incl. x-aimock-strict / x-test-id / x-diag-*) onto the outbound
// POST. These then arrive on req.headers at the Express endpoint, where the
// agent-side middleware reads them. Byte-identical to a plain fetch when no x-*
// are in scope, so demo traffic proxies unchanged.
const proxyHeaders = new AsyncLocalStorage<Record<string, string>>();
function extractXHeaders(req: NextRequest): Record<string, string> {
const out: Record<string, string> = {};
req.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (lower.startsWith("x-")) out[lower] = value;
});
return out;
}
const forwardingProxyFetch: NonNullable<HttpAgentConfig["fetch"]> = (
url,
requestInit,
) => {
const forwarded = proxyHeaders.getStore() ?? {};
if (Object.keys(forwarded).length === 0) return fetch(url, requestInit);
const merged = new Headers(requestInit?.headers);
for (const [k, v] of Object.entries(forwarded)) {
if (!merged.has(k)) merged.set(k, v);
}
return fetch(url, { ...requestInit, headers: merged });
};
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/`, fetch: forwardingProxyFetch });
}
// Register the same agent under all names used by demo pages.
// Strands runs a single shared backend agent; per-demo differentiation
// happens on the frontend (useFrontendTool / useRenderTool / useHumanInTheLoop
// / useAgentContext / A2UI catalogs). Every demo page's `agent=` prop must
// resolve to a name in this list.
const agentNames = [
// Original blitz set
"agentic_chat",
"human_in_the_loop",
"tool-rendering",
"gen-ui-tool-based",
"gen-ui-agent",
"shared-state-read",
"shared-state-write",
"shared-state-streaming",
"subagents",
// Chat UI / chrome demos
"chat-customization-css",
"prebuilt-sidebar",
"prebuilt-popup",
"chat-slots",
"headless-simple",
"headless-complete",
// Reasoning
"agentic-chat-reasoning",
"reasoning-default-render",
// Frontend tools
"frontend_tools",
"frontend-tools-async",
// HITL
"hitl-in-chat",
"hitl-in-app",
// Tool rendering variants
"tool-rendering-default-catchall",
"tool-rendering-custom-catchall",
"tool-rendering-reasoning-chain",
// State / context
"readonly-state-agent-context",
"shared-state-read-write",
// Modalities
"multimodal",
"voice",
// Misc
"auth",
"agent-config",
// BYOC renderers (wave 2)
"byoc-hashbrown-demo",
"byoc_json_render",
// Open Generative UI (wave 2)
"open-gen-ui",
"open-gen-ui-advanced",
// Polished chat shell (simplified port — wave 2 follow-up)
"beautiful-chat",
// Interrupt demos (Strategy B — frontend-tool async handler)
"gen-ui-interrupt",
"interrupt-headless",
];
const agents: Record<string, AbstractAgent> = {};
for (const name of agentNames) {
agents[name] = createAgent();
}
agents["default"] = createAgent();
console.log(
`[copilotkit/route] Registered ${Object.keys(agents).length} agent names: ${Object.keys(agents).join(", ")}`,
);
export const POST = async (req: NextRequest) =>
// Snapshot the inbound x-* into ALS for the duration of the request so the
// HttpAgent's forwardingProxyFetch can inject them onto the outbound POST to
// the Express agent. Only headers PRESENT inbound are forwarded — never
// hardcoded — so non-diagnostic demo traffic is byte-identical.
proxyHeaders.run(extractXHeaders(req), async () => {
const url = req.url;
const contentType = req.headers.get("content-type");
if (ROUTE_DEBUG) {
console.log(
`[copilotkit/route] POST ${url} (content-type: ${contentType})`,
);
}
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
}),
});
const response = await handleRequest(req);
if (!response.ok) {
console.log(`[copilotkit/route] Response status: ${response.status}`);
} else if (ROUTE_DEBUG) {
console.log(`[copilotkit/route] Response status: ${response.status}`);
}
return response;
} catch (error: unknown) {
const err = error as Error;
console.error(`[copilotkit/route] ERROR: ${err.message}`);
console.error(`[copilotkit/route] Stack: ${err.stack}`);
return NextResponse.json(
{ error: err.message, stack: err.stack },
{ status: 500 },
);
}
});
export const GET = async () => {
if (ROUTE_DEBUG) {
console.log("[copilotkit/route] GET /api/copilotkit (health probe)");
}
let agentStatus = "unknown";
try {
const res = await fetch(`${AGENT_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
agentStatus = res.ok ? "reachable" : `error (${res.status})`;
} catch (e: unknown) {
agentStatus = `unreachable (${(e as Error).message})`;
}
return NextResponse.json({
status: "ok",
agent_url: AGENT_URL,
agent_status: agentStatus,
env: {
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
NODE_ENV: process.env.NODE_ENV,
},
});
};
@@ -0,0 +1,49 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
export async function GET(req: NextRequest) {
// Token-gated: SHOWCASE_DEBUG_TOKEN must be set in env and matched
const token =
req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("token");
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
if (!expectedToken || !token || token !== expectedToken) {
return NextResponse.json({ error: "unauthorized" }, { status: 403 });
}
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// Agent connectivity
let agentStatus = "unknown";
let agentDetail = "";
try {
const res = await fetch(`${AGENT_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
agentStatus = res.ok ? "ok" : "error";
agentDetail = `HTTP ${res.status}`;
} catch (e: unknown) {
agentStatus = "down";
agentDetail = (e as Error).message;
}
const uptime = process.uptime();
const mem = process.memoryUsage();
return NextResponse.json({
integration: "strands",
uptime: `${Math.floor(uptime / 60)}m ${Math.floor(uptime % 60)}s`,
agent: { url: AGENT_URL, status: agentStatus, detail: agentDetail },
memory: {
rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)}MB`,
},
env: {
NODE_ENV: process.env.NODE_ENV,
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET",
LANGSMITH_API_KEY: process.env.LANGSMITH_API_KEY ? "set" : "NOT SET",
},
nodeVersion: process.version,
});
}
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
status: "ok",
integration: "strands-typescript",
timestamp: new Date().toISOString(),
});
}
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
const INTEGRATION_SLUG = "strands";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function GET() {
const start = Date.now();
// Hit our own /api/copilotkit endpoint — tests the full deployed stack
const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL ||
`http://localhost:${process.env.PORT || 3000}`;
try {
const res = await fetch(`${baseUrl}/api/copilotkit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
method: "agent/run",
params: { agentId: "agentic_chat" },
body: {
threadId: `smoke-${Date.now()}`,
runId: `smoke-run-${Date.now()}`,
state: {},
messages: [
{
id: `smoke-msg-${Date.now()}`,
role: "user",
content: "Respond with exactly: OK",
},
],
tools: [],
context: [],
forwardedProps: {},
},
}),
signal: AbortSignal.timeout(45000),
});
const latency = Date.now() - start;
if (!res.ok) {
const errBody = await res.text().catch(() => "");
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "runtime_response",
error: `Runtime returned ${res.status}: ${errBody.slice(0, 200)}`,
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
// TTFB: read first chunk only to confirm SSE stream started, then cancel
const reader = res.body?.getReader();
if (!reader) {
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned no readable body",
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
const { value, done } = await reader.read();
reader.cancel();
if (done || !value || value.length === 0) {
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned empty response body",
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
return NextResponse.json({
status: "ok",
integration: INTEGRATION_SLUG,
latency_ms: latency,
timestamp: new Date().toISOString(),
});
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
const latency = Date.now() - start;
let stage = "unknown";
if (err.name === "AbortError" || err.message.includes("timeout"))
stage = "timeout";
else if (
err.message.includes("fetch") ||
err.message.includes("ECONNREFUSED")
)
stage = "agent_unreachable";
else stage = "pipeline_error";
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage,
error: err.message,
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
}
@@ -0,0 +1,55 @@
// Shared fallback time-slot generator for the interrupt demos
// (`gen-ui-interrupt`, `interrupt-headless`). The interrupt backend
// (`src/agents/interrupt_agent.py`) supplies its own candidate slots
// inside the interrupt payload — these fallbacks only run if the
// payload arrives without them. Generating relative to `Date.now()`
// keeps the fallback from rotting, which previously had hardcoded
// dates that decayed within a week of being authored.
export interface TimeSlot {
label: string;
iso: string;
}
function atLocal(date: Date, hour: number, minute = 0): Date {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
hour,
minute,
0,
0,
);
}
function nextMonday(from: Date): Date {
// `getDay()` is 0=Sun, 1=Mon, ..., 6=Sat. We want the next Monday
// that's at LEAST 2 days away — otherwise "Monday" would collide
// with "Tomorrow" on Sunday (offset would be 1) or with itself on
// Monday (offset would be 0). Mirrors interrupt_agent.py.
const day = from.getDay();
let offset = (1 - day + 7) % 7;
if (offset <= 1) offset += 7;
const next = new Date(from);
next.setDate(from.getDate() + offset);
return next;
}
export function generateFallbackSlots(now: Date = new Date()): TimeSlot[] {
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
const monday = nextMonday(now);
const candidates: Array<[string, Date]> = [
["Tomorrow 10:00 AM", atLocal(tomorrow, 10)],
["Tomorrow 2:00 PM", atLocal(tomorrow, 14)],
["Monday 9:00 AM", atLocal(monday, 9)],
["Monday 3:30 PM", atLocal(monday, 15, 30)],
];
return candidates.map(([label, date]) => ({
label,
iso: date.toISOString(),
}));
}
@@ -0,0 +1,12 @@
// Coerces a tool-call result into a typed object. Tool results arrive
// as strings when the agent emits JSON or as already-parsed objects
// when the runtime decoded them upstream — this helper handles both
// shapes and returns `{}` if the result is missing or unparseable.
export function parseJsonResult<T>(result: unknown): T {
if (!result) return {} as T;
try {
return (typeof result === "string" ? JSON.parse(result) : result) as T;
} catch {
return {} as T;
}
}
@@ -0,0 +1,21 @@
// Helper for the CopilotChat slot overrides. The slot prop types in
// `@copilotkit/react-core` are nominally typed against the *exact*
// default component identity, but a custom wrapper that returns a
// structurally compatible ReactElement is functionally a drop-in. This
// helper names that fact and centralizes the type assertion in one
// place — readers see `makeSlotOverride` and know it's about the slot
// contract, not arbitrary type-system gymnastics. Once the slot prop
// types accept structural compatibility, this helper can be deleted
// and the casts will resolve automatically.
import type { ComponentType } from "react";
// `any` on the input is intentional: the helper's purpose is to accept
// any component shape and assert it as the slot's expected type. A
// stricter constraint would defeat the whole point.
export function makeSlotOverride<TDefault>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ComponentType<any>,
): TDefault {
return component as unknown as TDefault;
}
@@ -0,0 +1,31 @@
import * as React from "react";
/**
* ShadCN-style Badge primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
type Variant = "default" | "secondary" | "outline" | "success";
const variantClasses: Record<Variant, string> = {
default: "border-transparent bg-neutral-900 text-neutral-50",
secondary: "border-transparent bg-neutral-100 text-neutral-900",
outline: "border-neutral-200 text-neutral-700 bg-white",
success: "border-transparent bg-emerald-100 text-emerald-700",
};
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: Variant;
}
export function Badge({
className = "",
variant = "default",
...props
}: BadgeProps) {
return (
<div
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium tracking-wide ${variantClasses[variant]} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,48 @@
import * as React from "react";
/**
* ShadCN-style Button primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
type Variant = "default" | "outline" | "secondary" | "ghost" | "success";
type Size = "default" | "sm" | "lg";
const baseClasses =
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60";
const variantClasses: Record<Variant, string> = {
default: "bg-neutral-900 text-neutral-50 shadow-sm hover:bg-neutral-800",
outline:
"border border-neutral-200 bg-white text-neutral-900 shadow-sm hover:bg-neutral-100",
secondary: "bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-200",
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
success:
"bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm hover:bg-emerald-50",
};
const sizeClasses: Record<Size, string> = {
default: "h-10 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-11 rounded-md px-6",
};
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className = "", variant = "default", size = "default", ...props },
ref,
) => {
return (
<button
ref={ref}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
{...props}
/>
);
},
);
Button.displayName = "Button";
@@ -0,0 +1,61 @@
import * as React from "react";
/**
* ShadCN-style Card primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
export const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow-sm ${className}`}
{...props}
/>
));
Card.displayName = "Card";
export const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex flex-col space-y-1.5 p-5 pb-3 ${className}`}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
export const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className = "", ...props }, ref) => (
<h3
ref={ref}
className={`text-base font-semibold leading-none tracking-tight text-neutral-900 ${className}`}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
export const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div ref={ref} className={`p-5 pt-0 ${className}`} {...props} />
));
CardContent.displayName = "CardContent";
export const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex items-center p-5 pt-0 ${className}`}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
@@ -0,0 +1,26 @@
import * as React from "react";
/**
* ShadCN-style Separator primitive (inline-cloned for this demo).
* Plain Tailwind classes; uses a div instead of Radix to keep dependencies minimal.
*/
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical";
}
export function Separator({
className = "",
orientation = "horizontal",
...props
}: SeparatorProps) {
const orientationClasses =
orientation === "horizontal" ? "h-px w-full" : "h-full w-px";
return (
<div
role="separator"
aria-orientation={orientation}
className={`shrink-0 bg-neutral-200 ${orientationClasses} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,23 @@
"use client";
/**
* Fixed A2UI catalog — wires definitions to renderers.
*
* `includeBasicCatalog: true` merges CopilotKit's built-in components
* (Card, Column, Row, Text, Button, Divider, …) into this catalog, so
* the agent's fixed schema (src/agents/a2ui_schemas/flight_schema.json) can
* compose custom and basic components interchangeably.
*/
// @region[catalog-creation]
import { createCatalog } from "@copilotkit/a2ui-renderer";
import { definitions } from "./definitions";
import { renderers } from "./renderers";
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
export const catalog = createCatalog(definitions, renderers, {
catalogId: CATALOG_ID,
includeBasicCatalog: true,
});
// @endregion[catalog-creation]
@@ -0,0 +1,107 @@
/**
* A2UI catalog DEFINITIONS — platform-agnostic.
*
* Each entry declares a component name + its Zod props schema. The basic
* catalog (Card, Column, Row, Text, Button, …) ships with CopilotKit and
* is mixed in via `createCatalog(..., { includeBasicCatalog: true })`, so
* we only declare the project-specific additions and the visual overrides
* here. (Custom entries with the same name as a basic component override
* the basic one — Catalog dedupes by `comp.name`, last-write-wins.)
*
* IMPORTANT — path bindings: fields that can be bound to a data-model path
* (e.g. `{ path: "/origin" }` in the fixed schema JSON) must declare their
* Zod type as a union of `z.string()` and `z.object({ path: z.string() })`.
* The A2UI `GenericBinder` uses this union to detect the field as dynamic
* and resolve the path against the current data model at render time. Using
* plain `z.string()` causes the raw `{ path }` object to reach the
* renderer, which React then throws on (error #31 "object with keys {path}").
* This matches the canonical catalog's `DynString` helper:
* examples/integrations/langgraph-python/src/app/declarative-generative-ui/definitions.ts
*/
// @region[definitions-types]
import { z } from "zod";
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
/**
* Dynamic string: literal OR a data-model path binding. The GenericBinder
* resolves path bindings to the actual value at render time.
*/
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
export const definitions = {
/**
* Card override: gives the outer flight-card container a ShadCN look
* (rounded-xl, neutral-200 border, soft shadow). The basic catalog's
* Card uses inline styles; overriding here lets the demo's renderer
* adopt the demo's Tailwind aesthetic without touching the schema JSON.
*/
Card: {
description: "A container card with a single child.",
props: z.object({
child: z.string(),
}),
},
Title: {
description: "A prominent heading for the flight card.",
props: z.object({
text: DynString,
}),
},
Airport: {
description: "A 3-letter airport code, displayed large.",
props: z.object({
code: DynString,
}),
},
Arrow: {
description: "A right-pointing arrow used between airports.",
props: z.object({}),
},
AirlineBadge: {
description: "A pill-styled airline name tag.",
props: z.object({
name: DynString,
}),
},
PriceTag: {
description: "A stylized price display (e.g. '$289').",
props: z.object({
amount: DynString,
}),
},
/**
* Button override: swaps in an ActionButton renderer that tracks
* its own `done` state so clicking "Book flight" visually updates to
* a "Booked ✓" confirmation. The basic catalog's Button is stateless,
* so without this override the click fires the action but the button
* looks unchanged. Mirrors the pattern in beautiful-chat
* (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).
*/
Button: {
description:
"An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",
props: z.object({
child: z
.string()
.describe(
"The ID of the child component (e.g. a Text component for the label).",
),
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
action: z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.any()).optional(),
}),
}),
z.null(),
])
.optional(),
}),
},
} satisfies CatalogDefinitions;
// @endregion[definitions-types]
export type Definitions = typeof definitions;
@@ -0,0 +1,110 @@
"use client";
/**
* A2UI catalog RENDERERS — React implementations for the custom components
* declared in `./definitions`. TypeScript enforces that the renderer map's
* keys and prop shapes match the definitions exactly.
*
* Visual style: ShadCN aesthetic (neutral palette, rounded-xl, subtle
* borders, clean typography). Tailwind utility classes only — no `cn()` /
* `cva` helpers, no shadcn CLI install. Inline-cloned primitives live in
* `../_components/`.
*/
import React from "react";
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
import type { Definitions } from "./definitions";
import { Card } from "../_components/card";
import { Badge } from "../_components/badge";
import { Button as UIButton } from "../_components/button";
import { Separator } from "../_components/separator";
// `DynString` props are typed as `string | { path }` (see definitions.ts), but
// the A2UI binder resolves path bindings before render — renderers only ever
// see resolved strings. One shared helper keeps that narrowing in one place.
const s = (v: unknown): string => (typeof v === "string" ? v : "");
// @region[renderers-tsx]
export const renderers: CatalogRenderers<Definitions> = {
/**
* Card override: ShadCN-style outer container. The basic catalog's Card
* uses inline styles; overriding here keeps the demo's tailwind aesthetic.
* The flight schema renders Card > Column > [Title, Row, …]; the inner
* Column adds the vertical spacing.
*/
Card: ({ props, children }) => (
<Card className="w-full max-w-md p-5" data-testid="a2ui-fixed-card">
{props.child ? children(props.child) : null}
</Card>
),
Title: ({ props }) => (
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
Itinerary
</p>
<h3 className="text-base font-semibold leading-none tracking-tight text-neutral-900">
{s(props.text)}
</h3>
</div>
<Badge variant="outline" className="font-mono">
1-stop · economy
</Badge>
</div>
),
Airport: ({ props }) => (
<div className="flex flex-col items-center">
<span className="font-mono text-2xl font-semibold tracking-wider text-neutral-900">
{s(props.code)}
</span>
</div>
),
Arrow: () => (
<div className="flex flex-1 items-center px-3">
<Separator className="flex-1 bg-neutral-200" />
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mx-1 text-neutral-400"
aria-hidden
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
<Separator className="flex-1 bg-neutral-200" />
</div>
),
AirlineBadge: ({ props }) => (
<Badge variant="secondary" className="uppercase tracking-[0.08em]">
{s(props.name)}
</Badge>
),
PriceTag: ({ props }) => (
<div className="flex items-baseline gap-1">
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
Total
</span>
<span className="font-mono text-base font-semibold text-neutral-900">
{s(props.amount)}
</span>
</div>
),
/**
* Button override: this is a pure-presentation demo, so the button just
* renders its label. The schema declares an `action` for visual fidelity,
* but the click handler is inert until the Python SDK exposes
* `action_handlers=` on `a2ui.render` (see `src/agents/a2ui_fixed.py`).
*/
Button: ({ props, children }) => (
<UIButton className="w-full">
{props.child ? children(props.child) : null}
</UIButton>
),
};
// @endregion[renderers-tsx]
@@ -0,0 +1,11 @@
"use client";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { useA2UIFixedSchemaSuggestions } from "./suggestions";
export function Chat() {
useA2UIFixedSchemaSuggestions();
return (
<CopilotChat agentId="a2ui-fixed-schema" className="h-full rounded-2xl" />
);
}

Some files were not shown because too many files have changed in this diff Show More