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,3 @@
# URL of the Python Agent Spec agent's AG-UI streaming endpoint.
# Must match the path mounted in agent/concierge/server.py (default /run).
AGENT_URL=http://localhost:8000/run
@@ -0,0 +1,3 @@
# Standalone showcase: install with `npm install` (no committed lockfile).
package-lock.json
@@ -0,0 +1,70 @@
# End-to-end tests (Playwright)
These tests drive the **real CopilotKit (V2) chat UI** against the **live Agent
Spec agent** (LangGraph over AG-UI) and **Oracle AI Database**, and record every
run to video.
## What's covered
| Spec | Proves |
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `concierge.spec.ts` recalls a preference in a brand-new session | A unique fact (`FlyHigh-<ts>` program → `ZEPHYR-<ts>` number) taught in one thread is recalled after clicking **+ New thread** (same browser session, fresh conversation) — durable **user-scoped** memory in the Agent Spec stack, via Oracle. Runs first so it sees the freshly-reset store. |
| `concierge.spec.ts` finds a flight in a single turn | A first turn drives the Agent Spec server tools (`recall_memory` + `search_flights`) end to end; the assertion checks details from the canonical flight (`$740` / `KLM` / `AMS-001`, nonstop), which only appear in the assistant's reply. |
| `concierge.spec.ts` confirms before booking (HITL, single-run) | `book_flight` is a frontend **ClientTool**, so the confirm card → boarding pass resolves within **one** agent run (no second turn) — the adapter bug below is never triggered. Passes. |
## Determinism
`global-setup.ts` clears the demo user's memory before the suite (via
`e2e/reset-memory.py`). The concierge recalls through a **model-driven**
`recall_memory` tool and persists _every_ turn, so a retried recall would store
an "I don't have it" reply that poisons the next attempt; the cross-session test
therefore does **one** clean recall against the reset store, after settling so
the post-run memory write commits. **Heads-up:** the reset wipes `demo-user`'s
stored memories on every run.
### Known issue: multi-turn
A _second_ user turn in the same thread after a server-tool call trips an
upstream Agent Spec × AG-UI adapter bug (`tool_call_id` correlation). The
concierge sidesteps it: HITL booking runs as a **single** turn (`book_flight`
is a frontend ClientTool resolved in-run), and cross-session recall uses a
**new thread** rather than a follow-up turn — so every spec above is a first
turn. Details + repro:
[`docs/known-issues/agentspec-multiturn-toolcall-correlation.md`](../../docs/known-issues/agentspec-multiturn-toolcall-correlation.md).
## Prerequisites
From the repo root, with Oracle AI Database running and provisioned:
```bash
docker compose up -d
./db/setup-db.sh
```
The Playwright config (`../playwright.config.ts`) starts and **reuses** the rest:
- the **concierge agent** on `:8001` — a non-default port, so it won't collide
with a manual `npm run dev` agent on `:8000`; the config points the
frontend's `AGENT_URL` at `:8001/run` automatically, and
- the **frontend** on a dedicated test port `:3200`.
The agent's `.env` (with `OPENAI_API_KEY`) must be set up per the
[agent README](../../agent).
## Run
```bash
cd frontend
npm install # first time — pulls in @playwright/test
npx playwright install chromium # first time — downloads the browser
npm run test:e2e # run headless, record video
npm run test:e2e:headed # watch it drive the browser
npm run test:e2e:report # open the HTML report (video + trace)
```
## Videos
Every test records a `.webm` (gitignored) under `test-results/<test>/`. The
cross-session recall test now runs in a single browser context (teach, then
**+ New thread**, then recall), so it records one `video.webm` like the other
specs. The HTML report embeds the video and, on failure, a trace.
@@ -0,0 +1,169 @@
import { execFileSync } from "node:child_process";
import path from "node:path";
import { test, expect } from "@playwright/test";
import {
openChat,
newThread,
sendMessage,
sendAndAwaitRun,
askUntilReply,
assertNoAgentError,
} from "./helpers";
// Unique to *this* test run: a distinctive PROGRAM (the key — it appears in both
// the teaching message and the question) and FF_NUMBER (the answer — only in the
// teaching message and the recalled reply). The unique key lets semantic recall
// pin exactly this run's memory even though `demo-user` accumulates facts across
// runs and across both cookbook projects (which share the user).
const RUN = `${Date.now()}`;
const PROGRAM = `FlyHigh-${RUN}`;
const FF_NUMBER = `ZEPHYR-${RUN}`;
test.describe("Travel Concierge · Oracle Agent Spec × Memory", () => {
// Runs first, against the freshly-reset store (global-setup.ts). The concierge
// recalls through a *model-driven* `recall_memory` tool, and every turn —
// including a failed recall — is persisted; so a retry would persist an "I
// don't have it" reply that poisons the next attempt. We therefore do ONE
// clean recall, after ensuring the taught fact is committed.
test("recalls a preference in a brand-new session (cross-session memory)", async ({
page,
}) => {
// ── Session A — store a unique fact. The concierge persists the turn in a
// background task after the stream closes, then Oracle Agent Memory extracts
// + embeds + indexes it asynchronously, so the fact is not instantly
// recallable (we poll for it below before recalling).
await openChat(page);
await sendAndAwaitRun(
page,
`Please remember that my ${PROGRAM} frequent flyer number is ${FF_NUMBER}.`,
);
await assertNoAgentError(page);
// Block until the fact is actually searchable in Oracle (polling the same
// memory.search path recall_memory uses) before starting a fresh thread — a
// fixed sleep races the async indexing pipeline and makes recall flaky.
const agentDir = path.join(__dirname, "..", "..", "agent");
const waitScript = path.join(__dirname, "wait-until-searchable.py");
try {
execFileSync(
"uv",
["run", "--directory", agentDir, "python", waitScript, FF_NUMBER],
{ encoding: "utf8", stdio: "pipe", timeout: 150_000 },
);
} catch (err) {
const e = err as { stderr?: string; stdout?: string; message: string };
throw new Error(
`Taught fact never became searchable in Oracle: ${e.stderr || e.stdout || e.message}`,
{ cause: err },
);
}
// ── Recall — open a new thread via the sidebar. A new thread remounts
// CopilotChat with a fresh threadId, so the only source for the number is
// user-scoped Oracle memory recalled by recall_memory. One attempt, no
// retry (see comment at top of describe block).
await newThread(page);
await askUntilReply(
page,
`What is my ${PROGRAM} frequent flyer number? Use what you remember about me.`,
[new RegExp(FF_NUMBER, "i")],
{ attempts: 1, perAttemptMs: 120_000 },
);
await assertNoAgentError(page);
});
test("finds a flight in a single turn (recall_memory + search_flights)", async ({
page,
}) => {
await openChat(page);
// Exercises the server tools: recalls preferences, then searches flights.
// Assert on details from the canonical Amsterdam flight (AMS-001: KLM KL606,
// SFO → AMS, nonstop, $740) — these come from the assistant's reply, not
// the user's question (which only says "Amsterdam"), so this proves the
// search_flights tool actually ran and the model presented its result.
// One attempt, no retry: a retry would be a *second* turn after this turn's
// server tools ran, which trips the upstream multi-turn tool_call_id bug and
// can never succeed — so retrying only guarantees failure.
await askUntilReply(
page,
"Find me a flight to Amsterdam.",
[/740|KLM|AMS-001|nonstop/i],
{ attempts: 1, perAttemptMs: 120_000 },
);
await assertNoAgentError(page);
});
// HITL booking — works as a single run because `book_flight` is a frontend
// ClientTool: the confirmation card is rendered by the UI and resolved within
// the same agent run (no second user turn, so the upstream Agent Spec × AG-UI
// adapter bug with tool_call_id correlation is never triggered). Previously
// tracked in:
// docs/known-issues/agentspec-multiturn-toolcall-correlation.md
test("confirms before booking (HITL, single-run ClientTool)", async ({
page,
}) => {
await openChat(page);
// A fresh thread is not strictly required here (this is the first interaction
// in the test), but newThread() would also work if isolation is needed later.
// One attempt, no retry: the booking ask runs recall_memory (a server tool)
// in this turn, so a retry would be a second turn and trip the upstream
// multi-turn bug. Give the single attempt a generous window instead.
await askUntilReply(
page,
"Book me flight AMS-001 to Amsterdam.",
[/confirm your booking|confirm & book/i],
{ attempts: 1, perAttemptMs: 120_000 },
);
// Click the generative-UI confirmation card button surfaced by the ClientTool.
await page.getByRole("button", { name: /confirm & book/i }).click();
// Assert the boarding-pass badge ("CONFIRMED ✓"), not the echoed respond-payload
// string ("CONFIRMED — booked …"). The ✓ glyph appears only in the badge, so
// this fails before the run resolves instead of passing off the echoed payload.
await expect(page.getByText(/CONFIRMED ✓/)).toBeVisible({
timeout: 60_000,
});
await assertNoAgentError(page);
});
// The card-click booking path — distinct from the conversational HITL path
// above. Selecting a flight drives confirm → book entirely client-side in
// FlightOptions (no agent turn), so the confirm card renders inline in view
// and nothing is appended to the chat. Regression guard for the "select does
// nothing / confirm card scrolled off-screen" bug: the old path injected a
// "Book me flight …" user message and ran the agent; here we assert NO such
// message is ever appended.
test("books inline from the flight card (client-side select → confirm → book)", async ({
page,
}) => {
await openChat(page);
// Render the flight cards (search_flights genUI). One attempt, no retry: this
// turn runs server tools, so a retry would trip the upstream multi-turn bug.
await sendMessage(page, "Find me a flight to Amsterdam.");
const selectBtn = () =>
page.getByRole("button", { name: /select this flight/i }).first();
await expect(selectBtn()).toBeVisible({ timeout: 120_000 });
// Select → inline confirm card, with no agent round-trip (no injected message).
await selectBtn().click();
await expect(page.getByText(/confirm your booking/i)).toBeVisible({
timeout: 15_000,
});
await expect(page.getByText(/book me flight/i)).toHaveCount(0);
// Cancel → back to the flight list.
await page.getByRole("button", { name: /^cancel$/i }).click();
await expect(selectBtn()).toBeVisible({ timeout: 15_000 });
// Select again → confirm & book → boarding pass, still no agent turn.
await selectBtn().click();
await expect(page.getByText(/confirm your booking/i)).toBeVisible({
timeout: 15_000,
});
await page.getByRole("button", { name: /confirm & book/i }).click();
await expect(page.getByText(/CONFIRMED ✓/)).toBeVisible({
timeout: 15_000,
});
await expect(page.getByText(/book me flight/i)).toHaveCount(0);
await assertNoAgentError(page);
});
});
@@ -0,0 +1,120 @@
import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
// CopilotKit (V2) exposes stable test ids on the composer and send button.
const TEXTAREA = "copilot-chat-textarea";
const SEND_BUTTON = "copilot-send-button";
/** Load the app and wait for the chat composer to be interactive. */
export async function openChat(page: Page): Promise<void> {
await page.goto("/");
await expect(page.getByTestId(TEXTAREA)).toBeVisible({ timeout: 30_000 });
}
/** Type `text` into the composer and send it by clicking the send button. */
export async function sendMessage(page: Page, text: string): Promise<void> {
const input = page.getByTestId(TEXTAREA);
await input.click();
await input.fill(text);
// The send button enables once the composer is non-empty AND the component has
// hydrated. Waiting for that (rather than pressing Enter) avoids a first-
// interaction race where Enter no-ops before hydration completes.
const send = page.getByTestId(SEND_BUTTON);
await expect(send).toBeEnabled({ timeout: 15_000 });
await send.click();
// Sending clears the composer — a reliable signal the message was submitted.
await expect(input).toHaveValue("", { timeout: 10_000 });
}
/**
* Send a message and wait for the agent run's response stream to finish.
*
* NOTE: persistence is no longer coupled to stream close. The concierge writes
* memory in a background task AFTER the SSE stream closes at RUN_FINISHED, so a
* finished response is NOT a "memory written" signal. Callers that need the turn
* to be recallable must poll for it separately (see wait-until-searchable.py in
* the cross-session test); this only waits for the run to complete.
*/
export async function sendAndAwaitRun(page: Page, text: string): Promise<void> {
const streamClosed = page
.waitForResponse(
(r) =>
r.url().includes("/api/copilotkit") && r.request().method() === "POST",
{ timeout: 150_000 },
)
.then((r) => r.finished());
await sendMessage(page, text);
await streamClosed;
}
/**
* Ask a question and retry until the reply contains every expected pattern.
*
* IMPORTANT — retrying is unsafe for tool-driven prompts in this app.
* Each retry re-sends the question as a *new turn* in the same thread. After
* a server-tool call, that second turn trips the upstream multi-turn
* tool_call_id correlation bug and can never succeed. The default is therefore
* `attempts = 1`. Callers who need more time for a tool-driven prompt should
* raise `perAttemptMs` instead of `attempts`; `attempts > 1` is only safe for
* purely conversational (non-tool-driven) prompts.
*/
export async function askUntilReply(
page: Page,
question: string,
patterns: RegExp[],
{
attempts = 1,
perAttemptMs = 90_000,
gapMs = 3_000,
}: { attempts?: number; perAttemptMs?: number; gapMs?: number } = {},
): Promise<void> {
let missing: RegExp[] = patterns;
for (let i = 0; i < attempts; i++) {
await sendMessage(page, question);
const visible = await Promise.all(
patterns.map(async (p) => {
try {
await expect(page.getByText(p).first()).toBeVisible({
timeout: perAttemptMs,
});
return true;
} catch {
return false;
}
}),
);
missing = patterns.filter((_, idx) => !visible[idx]);
if (missing.length === 0) return;
if (i < attempts - 1) await page.waitForTimeout(gapMs);
}
throw new Error(
`No reply matching ${missing.map(String).join(", ")} after ${attempts} attempts`,
);
}
/** Fail fast if the runtime surfaced an error (incl. the known multi-turn bug). */
export async function assertNoAgentError(page: Page): Promise<void> {
await expect(
page.getByText(
/RUN_ERROR|agent_run_error|fetch failed|must be a response to/i,
),
).toHaveCount(0);
}
/**
* Click the sidebar "New thread" button and wait for the chat composer to be
* ready in the fresh, empty conversation. Each new thread mounts a new
* CopilotChat instance with its own threadId, so any prior server-tool state
* is isolated — use this instead of opening a new browser context when you
* only need a clean conversation, not a clean browser session.
*/
export async function newThread(page: Page): Promise<void> {
await page
.getByRole("button", { name: /new thread/i })
.first()
.click();
// The composer textarea must be present and empty before we start typing.
const input = page.getByTestId(TEXTAREA);
await expect(input).toBeVisible({ timeout: 15_000 });
await expect(input).toHaveValue("", { timeout: 10_000 });
}
@@ -0,0 +1,58 @@
"""Test support — purge one user's durable memory so the cross-session E2E tests
are deterministic regardless of prior runs.
Memory extraction is an LLM step: with many similar facts accumulated under the
shared demo user, it can conflate them (e.g. pair this run's unique key with an
older run's value), which makes recall non-deterministic. Clearing the user's
records before the suite removes that pollution.
`oracleagentmemory` keys its records by USER_ID, so a scoped DELETE across its
tables is a clean, well-defined purge (the VECTOR$ index on RECORD_CHUNKS
self-maintains on DML). Run automatically from `global-setup.ts`.
Usage: python reset-memory.py [user_id] (default: demo-user)
Needs ORACLE_DB_* in the agent's .env; run via the agent venv (`uv run`).
"""
from __future__ import annotations
import os
import sys
from dotenv import load_dotenv
# The agent's .env (…/<project>/agent/.env) is not an ancestor of this script, so
# point python-dotenv at it explicitly rather than relying on its search path.
_AGENT_ENV = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..", "agent", ".env"
)
load_dotenv(_AGENT_ENV)
USER = sys.argv[1] if len(sys.argv) > 1 else "demo-user"
# Children first; the VECTOR$ index on RECORD_CHUNKS auto-maintains on delete.
TABLES = ("RECORD_CHUNKS", "MEMORY", "MESSAGE", "THREAD")
try:
import oracledb
conn = oracledb.connect(
user=os.environ["ORACLE_DB_USER"],
password=os.environ["ORACLE_DB_PASSWORD"],
dsn=os.environ["ORACLE_DB_DSN"],
)
except Exception as exc: # DB down / not provisioned — let the tests surface it
print(f"[reset-memory] skipped: cannot connect ({exc})")
sys.exit(0)
cur = conn.cursor()
deleted: dict[str, object] = {}
for table in TABLES:
try:
cur.execute(f'DELETE FROM "{table}" WHERE USER_ID = :1', [USER])
deleted[table] = cur.rowcount
except Exception as exc:
# ORA-00942 (table not created yet) on a fresh database is fine.
deleted[table] = f"skip ({type(exc).__name__})"
conn.commit()
conn.close()
print(f"[reset-memory] cleared {USER!r}: {deleted}")
@@ -0,0 +1,44 @@
import { test, expect } from "@playwright/test";
import { openChat, sendMessage, newThread } from "./helpers";
// Regression for the reported bug: "New conversation thread name is the same as
// the prior conversation name." The old ThreadTitler ran a useEffect keyed on
// activeThreadId and read the shared, agentId-scoped agent.messages — on a thread
// switch that still held the PREVIOUS thread's transcript, so a freshly created
// thread was named after the prior conversation. The fix drives titling off the
// agent's own events (ThreadTitler subscribes via agent.subscribe to
// onMessagesChanged/onRunStartedEvent), gated on agent.threadId === activeThreadId,
// so a thread is only ever named from its own transcript.
//
// Titling fires when the user's message is added / the run starts — it does NOT wait
// for the agent's reply, so this test is fast and unaffected by memory/LLM latency.
const ITEM = '[data-testid="thread-item"]';
const ACTIVE = '[data-testid="thread-item"][data-active="true"]';
test.describe("Thread titles", () => {
test("a new thread is NOT named after the previous conversation", async ({
page,
}) => {
await openChat(page);
// Thread 1 is titled from its own first message.
const msg1 = "Plan a trip to Tokyo next spring";
await sendMessage(page, msg1);
await expect(page.locator(ACTIVE)).toContainText(msg1, { timeout: 15_000 });
// Start a new thread. The active thread MUST be the default title — not
// thread 1's title (the bug). And thread 1's title must be untouched.
await newThread(page);
await expect(page.locator(ACTIVE)).toContainText("New conversation", {
timeout: 15_000,
});
await expect(page.locator(ITEM).filter({ hasText: msg1 })).toHaveCount(1);
// The new thread is titled from ITS OWN first message, leaving thread 1 intact.
const msg2 = "Find hotels in Paris near the Louvre";
await sendMessage(page, msg2);
await expect(page.locator(ACTIVE)).toContainText(msg2, { timeout: 15_000 });
await expect(page.locator(ITEM).filter({ hasText: msg1 })).toHaveCount(1);
await expect(page.locator(ITEM)).toHaveCount(2);
});
});
@@ -0,0 +1,71 @@
"""Test support — block until a just-taught fact is recallable from Oracle.
The Agent Spec memory pipeline is asynchronous: after a turn is persisted, Oracle
Agent Memory extracts, embeds, and indexes it before it can be retrieved. A fact
taught moments ago is therefore not instantly searchable. The cross-session E2E
test must wait for that pipeline before asking in a fresh session — otherwise it
races indexing and recall returns nothing (a flaky failure that looks like a
product bug but is just a too-short delay).
This polls the SAME path `recall_memory` uses (`memory.search`) until the unique
token appears, then exits 0. Including the token in the query makes this a
reliable "is it indexed yet?" probe: the token can only appear in a result once
the fact is stored, so there are no false positives.
Usage: python wait-until-searchable.py <token> [user_id] [timeout_seconds]
Run via the agent venv: uv run --directory agent python <this> <token>
"""
from __future__ import annotations
import os
import sys
import time
# This helper lives in frontend/e2e/; the agent (its package + .env + venv deps)
# is two levels up. Put it on the path and load its .env explicitly.
_AGENT_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..", "agent"
)
sys.path.insert(0, _AGENT_DIR)
from dotenv import load_dotenv # noqa: E402
load_dotenv(os.path.join(_AGENT_DIR, ".env"))
TOKEN = sys.argv[1] if len(sys.argv) > 1 else ""
USER = sys.argv[2] if len(sys.argv) > 2 else "demo-user"
TIMEOUT = float(sys.argv[3]) if len(sys.argv) > 3 else 120.0
POLL = 3.0
if not TOKEN:
print("[wait-until-searchable] no token given", file=sys.stderr)
sys.exit(2)
from concierge.memory import get_memory # noqa: E402
from oracleagentmemory.apis.searchscope import SearchScope # noqa: E402
memory = get_memory()
scope = SearchScope(user_id=USER)
query = f"frequent flyer number {TOKEN}"
deadline = time.monotonic() + TIMEOUT
attempt = 0
while time.monotonic() < deadline:
attempt += 1
try:
results = list(memory.search(query=query, scope=scope))
except Exception as exc: # transient (index building, pool warm-up) — retry
print(
f"[wait-until-searchable] attempt {attempt}: {type(exc).__name__}: {exc}",
file=sys.stderr,
)
results = []
if any(TOKEN.lower() in (getattr(r, "content", "") or "").lower() for r in results):
elapsed = int(TIMEOUT - (deadline - time.monotonic()))
print(f"[wait-until-searchable] {TOKEN!r} searchable after ~{elapsed}s ({attempt} polls)")
sys.exit(0)
time.sleep(POLL)
print(f"[wait-until-searchable] {TOKEN!r} NOT searchable within {TIMEOUT:.0f}s", file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,18 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
const eslintConfig = [
...nextCoreWebVitals,
...nextTypescript,
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;
@@ -0,0 +1,24 @@
import { execFileSync } from "node:child_process";
import path from "node:path";
// Purge the demo user's durable memory before the suite so the cross-session
// recall test is deterministic (no stale facts from earlier runs). Runs the
// reset through the agent's venv via `uv`. Non-fatal: if it can't run, the
// suite still runs and any real DB problem surfaces in the tests themselves.
export default function globalSetup() {
const frontendDir = __dirname;
const agentDir = path.join(frontendDir, "..", "agent");
const script = path.join(frontendDir, "e2e", "reset-memory.py");
try {
const out = execFileSync(
"uv",
["run", "--directory", agentDir, "python", script],
{ encoding: "utf8", stdio: "pipe" },
);
process.stdout.write(out);
} catch (err) {
console.warn(
`[global-setup] memory reset skipped: ${(err as Error).message}`,
);
}
}
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
@@ -0,0 +1,41 @@
{
"name": "agentspec-memory-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:report": "playwright show-report"
},
"dependencies": {
"@ag-ui/client": "^0.0.46",
"@ag-ui/core": "^0.0.46",
"@ag-ui/encoder": "^0.0.46",
"@ag-ui/proto": "^0.0.46",
"@copilotkit/react-core": "0.0.0-mme-ag-ui-0-0-46-20260227141603",
"@copilotkit/react-ui": "0.0.0-mme-ag-ui-0-0-46-20260227141603",
"@copilotkit/runtime": "0.0.0-mme-ag-ui-0-0-46-20260227141603",
"@copilotkit/runtime-client-gql": "0.0.0-mme-ag-ui-0-0-46-20260227141603",
"@copilotkit/shared": "0.0.0-mme-ag-ui-0-0-46-20260227141603",
"hono": "^4.11.4",
"next": "16.0.10",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"zod": "^3.25.75"
},
"devDependencies": {
"@playwright/test": "^1.61.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.10",
"tailwindcss": "^4",
"typescript": "5.9.2"
}
}
@@ -0,0 +1,58 @@
import { defineConfig, devices } from "@playwright/test";
import path from "node:path";
// End-to-end tests drive the real CopilotKit (V2) chat UI against the live Agent
// Spec agent (LangGraph over AG-UI) and Oracle AI Database. Recorded to video.
//
// This agent runs on :8001 so it won't collide with a manual dev agent on :8000
// (the agent defaults to :8000), so we override the frontend's AGENT_URL here.
//
// Prerequisites (reused if already running):
// 1. Oracle AI Database up + `cookbook` user provisioned (repo-root README).
// 2. The concierge agent on :8001 — Playwright starts it if it isn't.
const FRONTEND_PORT = 3200;
const AGENT_PORT = 8001;
const AGENT_URL = `http://127.0.0.1:${AGENT_PORT}/run`;
const agentDir = path.join(__dirname, "..", "agent");
export default defineConfig({
testDir: "./e2e",
outputDir: "./test-results",
// Clear the demo user's memory before the suite so cross-session recall is
// deterministic (see global-setup.ts).
globalSetup: "./global-setup.ts",
// Tests share one server-side memory store (the `demo-user`); run them in order.
fullyParallel: false,
workers: 1,
retries: 0,
// Agent Spec turns (recall + tool calls + LLM) are slow, and the cross-session
// test runs two of them back to back.
timeout: 300_000,
expect: { timeout: 120_000 },
reporter: [["list"], ["html", { open: "never" }]],
use: {
baseURL: `http://localhost:${FRONTEND_PORT}`,
viewport: { width: 1280, height: 720 },
video: { mode: "on", size: { width: 1280, height: 720 } },
trace: "retain-on-failure",
actionTimeout: 30_000,
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: [
{
command: `uv run python -m uvicorn concierge.server:app --host 127.0.0.1 --port ${AGENT_PORT}`,
cwd: agentDir,
url: `http://127.0.0.1:${AGENT_PORT}/health`,
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
{
command: `npm run dev -- --port ${FRONTEND_PORT}`,
url: `http://localhost:${FRONTEND_PORT}`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: { AGENT_URL },
},
],
});
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
@@ -0,0 +1,8 @@
{
"$schema": "https://railway.com/railway.schema.json",
"build": { "builder": "NIXPACKS" },
"deploy": {
"startCommand": "npm run start -- --hostname 0.0.0.0 --port $PORT",
"restartPolicyType": "ON_FAILURE"
}
}
@@ -0,0 +1,26 @@
import {
CopilotRuntime,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { handle } from "hono/vercel";
import { HttpAgent } from "@ag-ui/client";
// Proxy to the Python Agent Spec agent (LangGraph) over AG-UI. The agent owns
// the LLM and the memory, so no service adapter / LLM key lives here.
const agent = new HttpAgent({
url:
process.env.AGENT_URL ||
process.env.NEXT_PUBLIC_AGENT_URL ||
"http://localhost:8000/run",
});
const runtime = new CopilotRuntime({
agents: { oracle_concierge: agent },
runner: new InMemoryAgentRunner(),
});
const app = createCopilotEndpoint({ runtime, basePath: "/api/copilotkit" });
export const GET = handle(app);
export const POST = handle(app);
@@ -0,0 +1,5 @@
@import "tailwindcss";
body {
font-family: ui-sans-serif, system-ui, sans-serif;
}
@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Oracle Agent Spec × Memory × CopilotKit",
description:
"A travel concierge with long-term memory on Oracle AI Database.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
@@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import "@copilotkit/react-core/v2/styles.css";
import { CopilotKitProvider, CopilotChat } from "@copilotkit/react-core/v2";
import { useThreadStore } from "@/lib/threads";
import { ThreadSidebar } from "@/components/ThreadSidebar";
import { ConciergeTools } from "@/components/ConciergeTools";
import { ErrorNotice } from "@/components/ErrorNotice";
import { ThreadTitler } from "@/components/ThreadTitler";
export default function Home() {
const {
ready,
threads,
activeThreadId,
newThread,
selectThread,
renameThread,
} = useThreadStore();
const [collapsed, setCollapsed] = useState(false);
const activeTitle = threads.find((t) => t.id === activeThreadId)?.title ?? "";
return (
<CopilotKitProvider runtimeUrl="/api/copilotkit">
<div className="flex h-screen bg-gray-50">
<ThreadSidebar
threads={threads}
activeThreadId={activeThreadId}
collapsed={collapsed}
onToggle={() => setCollapsed((c) => !c)}
onNewThread={newThread}
onSelectThread={selectThread}
/>
<main className="flex-1 flex flex-col min-w-0">
{/* Header */}
<header className="border-b border-gray-200 bg-white px-6 py-3 shrink-0">
<h1 className="text-lg font-semibold text-gray-900 leading-tight">
Travel Concierge · Oracle Agent Spec × Memory
</h1>
<p className="text-xs text-gray-500 mt-0.5">
Ask about destinations, get personalized recommendations, and let
the agent remember your preferences across sessions.{" "}
<span className="text-gray-400">
Tip: start a New thread to test cross-session memory.
</span>
</p>
</header>
{/* Tool renderers — mount once, render inline in the chat stream */}
<ConciergeTools />
{/* Surfaces run errors the chat UI would otherwise swallow */}
<ErrorNotice />
{/* Names a thread after its first user message */}
<ThreadTitler
activeThreadId={activeThreadId}
activeTitle={activeTitle}
onTitle={renameThread}
/>
{/* Chat region */}
<div className="flex-1 min-h-0">
{ready && activeThreadId ? (
<CopilotChat
agentId="oracle_concierge"
threadId={activeThreadId}
key={activeThreadId}
className="h-full"
/>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-gray-400">Loading</p>
</div>
)}
</div>
</main>
</div>
</CopilotKitProvider>
);
}
@@ -0,0 +1,136 @@
"use client";
import type { Flight } from "@/lib/flights";
import { formatTime } from "@/lib/flights";
interface BoardingPassProps {
flight?: Flight;
flightId: string;
booked?: boolean;
}
export function BoardingPass({ flight, flightId, booked }: BoardingPassProps) {
return (
<div className="mt-3 rounded-xl overflow-hidden border border-gray-200 bg-white shadow-sm max-w-lg">
{/* Colored top stripe */}
<div className="h-2 bg-indigo-600" />
<div className="flex divide-x divide-dashed divide-gray-300">
{/* Main section */}
<div className="flex-1 p-5 space-y-3">
{flight ? (
<>
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-400 uppercase tracking-wide">
Airline
</p>
<p className="text-sm font-semibold text-gray-900">
{flight.airline}
</p>
</div>
<span className="font-mono text-xs text-gray-400">
{flight.flight_no}
</span>
</div>
<div>
<p className="text-xs text-gray-400 uppercase tracking-wide mb-0.5">
Route
</p>
<p className="text-xl font-bold text-gray-900 tracking-tight">
{flight.origin} {flight.destination}
</p>
</div>
<div className="flex gap-6">
<div>
<p className="text-xs text-gray-400 uppercase tracking-wide">
Departs
</p>
<p className="text-sm font-semibold text-gray-900 tabular-nums">
{formatTime(flight.depart)}
</p>
</div>
<div>
<p className="text-xs text-gray-400 uppercase tracking-wide">
Arrives
</p>
<p className="text-sm font-semibold text-gray-900 tabular-nums">
{formatTime(flight.arrive)}
</p>
</div>
</div>
<div className="flex gap-6">
<div>
<p className="text-xs text-gray-400 uppercase tracking-wide">
Duration
</p>
<p className="text-sm text-gray-700">{flight.duration}</p>
</div>
<div>
<p className="text-xs text-gray-400 uppercase tracking-wide">
Class
</p>
<p className="text-sm text-gray-700">{flight.cabin}</p>
</div>
</div>
</>
) : (
<div>
<p className="text-xs text-gray-400 uppercase tracking-wide mb-1">
Flight
</p>
<p className="font-mono text-sm text-gray-700">{flightId}</p>
</div>
)}
</div>
{/* Stub section */}
<div className="w-36 p-4 flex flex-col justify-between bg-gray-50/60">
<div className="space-y-3">
<div>
<p className="text-xs text-gray-400 uppercase tracking-wide">
Seat
</p>
<p className="text-sm font-semibold text-gray-900">Aisle</p>
</div>
<div>
<p className="text-xs text-gray-400 uppercase tracking-wide">
Gate
</p>
<p className="text-sm font-semibold text-gray-900"></p>
</div>
<div>
<p className="text-xs text-gray-400 uppercase tracking-wide">
Boarding
</p>
<p className="text-sm font-semibold text-gray-900"></p>
</div>
{flight && (
<div>
<p className="text-xs text-gray-400 uppercase tracking-wide">
Price
</p>
<p className="text-sm font-bold text-indigo-600 tabular-nums">
{typeof flight?.price_usd === "number"
? `$${flight.price_usd.toLocaleString()}`
: "—"}
</p>
</div>
)}
</div>
{booked && (
<div className="mt-3">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-1 text-xs font-semibold text-emerald-700">
CONFIRMED
</span>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import type { Flight } from "@/lib/flights";
import { formatTime } from "@/lib/flights";
interface BookingConfirmCardProps {
flight?: Flight;
flightId: string;
onConfirm: () => void | Promise<void>;
onCancel: () => void | Promise<void>;
}
export function BookingConfirmCard({
flight,
flightId,
onConfirm,
onCancel,
}: BookingConfirmCardProps) {
const [submitting, setSubmitting] = useState(false);
return (
<div className="mt-3 rounded-xl border border-indigo-200 bg-indigo-50/40 p-5 space-y-4">
<h3 className="text-base font-semibold text-gray-900">
Confirm your booking
</h3>
{flight ? (
<div className="space-y-2">
<div className="text-lg font-bold text-gray-900">
{flight.origin} {flight.destination}
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<span className="font-medium">{flight.airline}</span>
<span className="text-gray-400 font-mono text-xs">
{flight.flight_no}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600 tabular-nums">
<span>{formatTime(flight.depart)}</span>
<span className="text-gray-300"></span>
<span>{formatTime(flight.arrive)}</span>
<span className="text-gray-400">·</span>
<span>{flight.duration}</span>
</div>
<div className="text-xl font-bold text-indigo-600 tabular-nums">
{typeof flight?.price_usd === "number"
? `$${flight.price_usd.toLocaleString()}`
: "—"}
</div>
</div>
) : (
<p className="text-sm text-gray-600">
Flight{" "}
<span className="font-mono text-xs bg-white border border-gray-200 rounded px-1.5 py-0.5">
{flightId}
</span>
</p>
)}
<div className="flex items-center gap-3 pt-1">
<button
type="button"
disabled={submitting}
onClick={async () => {
setSubmitting(true);
try {
await onConfirm();
} finally {
setSubmitting(false);
}
}}
className="inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-60"
>
Confirm &amp; book
</button>
<button
type="button"
disabled={submitting}
onClick={async () => {
setSubmitting(true);
try {
await onCancel();
} finally {
setSubmitting(false);
}
}}
className="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-50 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-400 disabled:opacity-60"
>
Cancel
</button>
</div>
</div>
);
}
@@ -0,0 +1,108 @@
"use client";
import {
useRenderTool,
useHumanInTheLoop,
useDefaultRenderTool,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
import { z } from "zod";
import { FlightOptions } from "@/components/FlightOptions";
import { RecallChip } from "@/components/RecallChip";
import { BookingConfirmCard } from "@/components/BookingConfirmCard";
import { BoardingPass } from "@/components/BoardingPass";
import { parseFlights, getFlight } from "@/lib/flights";
export function ConciergeTools() {
// Starter-prompt chips shown on each empty thread — they walk the user through
// the whole demo: teach prefs → search (cards) → book (HITL) → recall in a new thread.
useConfigureSuggestions({
available: "before-first-message",
suggestions: [
{
title: "Set my travel prefs",
message:
"Remember that I fly out of SFO, prefer aisle seats, and like vegetarian meals.",
},
{
title: "Find a flight to Amsterdam",
message: "Find me a flight to Amsterdam.",
},
{
title: "Book the nonstop",
message: "Book me flight AMS-001 to Amsterdam.",
},
{
title: "What do you remember?",
message: "What do you remember about my travel preferences?",
},
],
});
useRenderTool({
name: "search_flights",
parameters: z.object({ destination: z.string() }),
render: ({ status, parameters, result }) => {
if (status !== "complete") {
return (
<p className="text-sm text-gray-500 py-2">
Searching flights to {parameters?.destination ?? "your destination"}
</p>
);
}
return <FlightOptions flights={parseFlights(result as string)} />;
},
});
useRenderTool({
name: "recall_memory",
parameters: z.object({ query: z.string() }),
render: ({ status, result }) => (
<RecallChip
memories={status === "complete" ? (result as string) : undefined}
/>
),
});
useHumanInTheLoop({
name: "book_flight",
description:
"Confirm with the traveler, then book the chosen flight by its id.",
parameters: z.object({ flight_id: z.string() }),
render: ({ status, args, respond }) => {
const id = (args?.flight_id as string) ?? "";
if (status === "complete")
return <BoardingPass flightId={id} flight={getFlight(id)} booked />;
if (status !== "executing" || !respond) return <></>;
const flight = getFlight(id);
return (
<BookingConfirmCard
flight={flight}
flightId={id}
onConfirm={async () => {
try {
await respond(
`CONFIRMED — booked ${flight?.flight_no ?? id} (${id}). Confirmation sent.`,
);
} catch (e) {
console.error("book_flight respond failed", e);
}
}}
onCancel={async () => {
try {
await respond(
"CANCELLED — the traveler declined; no booking was made.",
);
} catch (e) {
console.error("book_flight respond failed", e);
}
}}
/>
);
},
});
useDefaultRenderTool();
return null;
}
@@ -0,0 +1,71 @@
"use client";
import { useEffect, useState } from "react";
import { useAgent } from "@copilotkit/react-core/v2";
const AGENT_ID = "oracle_concierge";
/** A friendlier, shorter message for known failure shapes. */
function humanize(raw: string): string {
if (
/must be a response to a preceeding message with 'tool_calls'/.test(raw)
) {
return "This conversation hit a known multi-turn limitation in the Agent Spec × AG-UI adapter. Start a new thread to continue.";
}
return "The agent run failed. Please try again or start a new thread.";
}
/**
* Surfaces a run's RUN_ERROR, which the chat UI otherwise swallows (it arrives
* after RUN_FINISHED), so a failed turn no longer looks like a dead app.
*/
export function ErrorNotice() {
const { agent } = useAgent({ agentId: AGENT_ID });
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!agent) return;
const { unsubscribe } = agent.subscribe({
// Clear any prior error when a new run begins.
onRunStartedEvent: () => setError(null),
// SSE RUN_ERROR (e.g. the agent raised mid-run) — chat UI swallows this.
onRunErrorEvent: ({ event }: { event: { message?: string } }) => {
setError(event?.message || "Unknown error");
},
// Run threw (e.g. the agent endpoint is unreachable).
onRunFailed: ({ error }: { error: Error }) => {
setError(error?.message || String(error));
},
});
return unsubscribe;
}, [agent]);
if (!error) return null;
return (
<div className="mx-4 mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-800 flex items-start gap-3">
<span aria-hidden="true" className="mt-0.5">
</span>
<div className="flex-1 min-w-0">
<p className="font-medium">{humanize(error)}</p>
<details className="mt-1">
<summary className="cursor-pointer text-xs text-red-600/80 hover:text-red-700">
Technical details
</summary>
<pre className="mt-1 whitespace-pre-wrap break-words text-[11px] text-red-700/70 font-mono max-h-32 overflow-auto">
{error}
</pre>
</details>
</div>
<button
type="button"
onClick={() => setError(null)}
aria-label="Dismiss"
className="shrink-0 text-red-400 hover:text-red-600 cursor-pointer"
>
</button>
</div>
);
}
@@ -0,0 +1,164 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { Flight } from "@/lib/flights";
import { formatTime, stopsLabel } from "@/lib/flights";
import { BookingConfirmCard } from "@/components/BookingConfirmCard";
import { BoardingPass } from "@/components/BoardingPass";
export function FlightCard({
flight,
onSelect,
}: {
flight: Flight;
onSelect?: (flight: Flight) => void;
}) {
const isNonstop = flight.stops === 0;
const selectable = Boolean(onSelect);
return (
<div
className={`rounded-xl border bg-white shadow-sm p-4 flex flex-col gap-3 transition-colors ${
selectable
? "border-gray-200 hover:border-indigo-300 hover:bg-indigo-50/30"
: "border-gray-200"
}`}
>
<div className="flex items-start gap-4">
{/* Left: route + times */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-gray-900 text-sm">
{flight.airline}
</span>
<span className="text-xs text-gray-400 font-mono">
{flight.flight_no}
</span>
</div>
<div className="text-base font-bold text-gray-900 mb-1">
{flight.origin} {flight.destination}
</div>
<div className="flex items-center gap-2 text-sm text-gray-600 flex-wrap mb-2">
<span className="tabular-nums">{formatTime(flight.depart)}</span>
<span className="text-gray-300"></span>
<span className="tabular-nums">{formatTime(flight.arrive)}</span>
<span className="text-gray-400">·</span>
<span>{flight.duration}</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
isNonstop
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-amber-50 text-amber-700 border border-amber-200"
}`}
>
{stopsLabel(flight.stops)}
</span>
<span className="text-xs text-gray-500 border border-gray-200 rounded-full px-2 py-0.5">
{flight.cabin}
</span>
</div>
{flight.notes && (
<p className="mt-2 text-xs text-gray-400 truncate">
{flight.notes}
</p>
)}
</div>
{/* Right: price + id */}
<div className="flex flex-col items-end shrink-0 gap-1">
<span className="text-xl font-bold text-indigo-600 tabular-nums">
{typeof flight.price_usd === "number"
? `$${flight.price_usd.toLocaleString()}`
: "—"}
</span>
<span className="text-[10px] text-gray-300 font-mono">
{flight.id}
</span>
</div>
</div>
{/* Selector — opens the booking confirm inline (no agent round-trip), so the
confirm step renders right here in view instead of being appended below
the fold by a fresh agent turn. */}
{selectable && (
<button
type="button"
onClick={() => onSelect?.(flight)}
className="self-end inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 transition-colors cursor-pointer"
>
Select this flight
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
aria-hidden="true"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</button>
)}
</div>
);
}
export function FlightOptions({ flights = [] }: { flights?: Flight[] }) {
// The whole select → confirm → book flow is local to this card: no agent run,
// so nothing gets appended to the chat stream and the booking UI can never be
// scrolled out of view. The conversational "Book me flight X" path still goes
// through the agent's book_flight HITL tool.
const [chosen, setChosen] = useState<Flight | null>(null);
const [booked, setBooked] = useState(false);
const focusRef = useRef<HTMLDivElement>(null);
// Keep the confirm card / boarding pass in view as it replaces the list.
useEffect(() => {
if (chosen) focusRef.current?.scrollIntoView({ block: "nearest" });
}, [chosen, booked]);
if (flights.length === 0) {
return <p className="text-sm text-gray-400 py-2">No flights found.</p>;
}
if (chosen) {
return (
<div ref={focusRef}>
{booked ? (
<BoardingPass flightId={chosen.id} flight={chosen} booked />
) : (
<BookingConfirmCard
flight={chosen}
flightId={chosen.id}
onConfirm={() => setBooked(true)}
onCancel={() => setChosen(null)}
/>
)}
</div>
);
}
return (
<div className="mt-3 space-y-1">
<p className="text-sm font-medium text-gray-500 mb-2">
Flight options
</p>
<div className="grid gap-3">
{flights.map((flight, i) => (
<FlightCard
key={flight.id ?? `${flight.flight_no ?? "flight"}-${i}`}
flight={flight}
onSelect={setChosen}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
interface RecallChipProps {
memories?: string;
}
const EMPTY_SENTINEL = "No relevant memories.";
/** Split the newline-separated recall result into clean preference lines. */
function parsePreferences(memories: string): string[] {
return memories
.split("\n")
.map((line) => line.replace(/^[-•*]\s*/, "").trim())
.filter((line) => line.length > 0 && line !== EMPTY_SENTINEL);
}
export function RecallChip({ memories }: RecallChipProps) {
const [open, setOpen] = useState(false);
const hasMemories = Boolean(memories && memories.trim().length > 0);
// Still recalling — non-interactive placeholder.
if (!hasMemories) {
return (
<span className="text-xs rounded-full bg-gray-100 px-2.5 py-1 text-gray-600 inline-flex items-center gap-1">
🧠 Recalling your preferences
</span>
);
}
const prefs = parsePreferences(memories as string);
const hasPrefs = prefs.length > 0;
return (
<div className="mt-3 inline-flex flex-col items-start gap-1">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="text-xs rounded-full bg-gray-100 px-2.5 py-1 text-gray-600 inline-flex items-center gap-1 hover:bg-gray-200 transition-colors cursor-pointer"
>
🧠 Remembered your preferences
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
className={`transition-transform ${open ? "rotate-180" : ""}`}
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{open && (
<div className="rounded-lg border border-gray-200 bg-white shadow-sm px-3 py-2 text-xs text-gray-600">
{hasPrefs ? (
<ul className="space-y-1">
{prefs.map((pref, i) => (
<li key={i} className="flex items-start gap-1.5">
<span className="text-gray-300 leading-none mt-0.5"></span>
<span>{pref}</span>
</li>
))}
</ul>
) : (
<p className="text-gray-400">Nothing saved yet for this query.</p>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,107 @@
"use client";
import type { Thread } from "@/lib/threads";
interface ThreadSidebarProps {
threads: Thread[];
activeThreadId: string;
collapsed: boolean;
onToggle: () => void;
onNewThread: () => void;
onSelectThread: (id: string) => void;
}
export function ThreadSidebar({
threads,
activeThreadId,
collapsed,
onToggle,
onNewThread,
onSelectThread,
}: ThreadSidebarProps) {
return (
<div
className="h-full shrink-0 min-w-0 overflow-hidden border-r border-gray-200 bg-white flex flex-col"
style={{ width: collapsed ? "3.5rem" : "16rem" }}
>
{collapsed ? (
<div className="flex flex-col items-center gap-2 pt-3 px-2">
<button
onClick={onToggle}
aria-label="Expand sidebar"
className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-gray-100 text-gray-600 text-lg"
>
&#9776;
</button>
<button
onClick={onNewThread}
aria-label="New thread"
className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-gray-100 text-gray-600 text-lg font-semibold"
>
+
</button>
</div>
) : (
<>
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 shrink-0">
<span className="text-sm font-semibold text-gray-800 tracking-wide">
Conversations
</span>
<button
onClick={onToggle}
aria-label="Collapse sidebar"
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-gray-100 text-gray-500"
>
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="10 4 6 8 10 12" />
<polyline points="6 4 2 8 6 12" />
</svg>
</button>
</div>
<div className="px-3 py-3 shrink-0">
<button
onClick={onNewThread}
className="w-full bg-indigo-600 text-white rounded-lg px-3 py-2 text-sm font-medium hover:bg-indigo-700 transition-colors"
>
+ New thread
</button>
</div>
<div className="flex-1 overflow-y-auto px-2 pb-3">
{threads.map((t) => {
const isActive = t.id === activeThreadId;
return (
<button
key={t.id}
data-testid="thread-item"
data-active={isActive}
onClick={() => onSelectThread(t.id)}
className={`w-full text-left rounded-lg px-3 py-2 mb-1 transition-colors ${
isActive
? "bg-indigo-50 text-indigo-700 font-medium"
: "text-gray-700 hover:bg-gray-50"
}`}
>
<div className="truncate text-sm leading-snug">{t.title}</div>
<div className="text-xs text-gray-400 mt-0.5">
{new Date(t.createdAt).toLocaleDateString()}
</div>
</button>
);
})}
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useEffect } from "react";
import { useAgent } from "@copilotkit/react-core/v2";
import { DEFAULT_THREAD_TITLE, titleFromText } from "@/lib/threads";
const AGENT_ID = "oracle_concierge";
type MinimalMessage = { role?: string; content?: unknown };
/** Text of the first user message in a transcript (handles string or parts). */
function firstUserText(messages: MinimalMessage[]): string {
const firstUser = messages.find((m) => m?.role === "user");
const raw = firstUser?.content;
if (typeof raw === "string") return raw;
if (Array.isArray(raw)) {
return raw
.map((p) =>
typeof p === "string" ? p : ((p as { text?: string })?.text ?? ""),
)
.join(" ");
}
return "";
}
/**
* Names a thread after its first user message. Mounted (render-null) inside the
* CopilotKit provider so it can read the active thread's transcript via useAgent.
*
* It titles ONLY when the shared agent is actually on the active thread
* (`agent.threadId === activeThreadId`) and that thread still carries the default
* title. This is the fix for "a new thread reuses the prior thread's name": the
* previous implementation ran a useEffect keyed on `activeThreadId`, so on a thread
* switch it read the shared, agentId-scoped `agent.messages` *before* CopilotChat's
* connect cleared them — naming the fresh thread after the prior conversation.
*
* Instead we drive titling off the agent's own message/run events. Those fire on
* `addMessage` (the user's submit, by which point `agent.threadId` is the active
* thread) and on the switch-time `setMessages([])` (empty transcript → no title) —
* never with another thread's transcript while `threadId` already points here.
*/
export function ThreadTitler({
activeThreadId,
activeTitle,
onTitle,
}: {
activeThreadId: string;
activeTitle: string;
onTitle: (id: string, title: string) => void;
}) {
const { agent } = useAgent({ agentId: AGENT_ID });
useEffect(() => {
if (!agent) return;
const tryTitle = () => {
if (!activeThreadId || activeTitle !== DEFAULT_THREAD_TITLE) return;
// Only title the thread the agent has actually switched to — guards against
// reading the previous thread's still-loaded transcript during a switch.
if (agent.threadId !== activeThreadId) return;
const title = titleFromText(
firstUserText((agent.messages ?? []) as MinimalMessage[]),
);
if (title) onTitle(activeThreadId, title);
};
const sub = agent.subscribe({
onMessagesChanged: tryTitle,
onRunStartedEvent: tryTitle,
});
return () => sub.unsubscribe();
}, [agent, activeThreadId, activeTitle, onTitle]);
return null;
}
@@ -0,0 +1,77 @@
"use client";
export type Flight = {
id: string;
airline: string;
flight_no: string;
origin: string;
destination: string;
depart: string;
arrive: string;
duration: string;
stops: number;
cabin: string;
price_usd: number;
notes: string;
};
const _cache = new Map<string, Flight>();
export function rememberFlights(list: Flight[]): void {
for (const f of list) {
if (f && typeof f === "object" && typeof f.id === "string") {
_cache.set(f.id, f);
}
}
}
export function getFlight(id: string): Flight | undefined {
return _cache.get(id);
}
export function parseFlights(result: string): Flight[] {
try {
const parsed: unknown = JSON.parse(result);
if (Array.isArray(parsed)) {
const flights: Flight[] = parsed
.filter(
(el): el is Record<string, unknown> =>
el != null &&
typeof el === "object" &&
typeof (el as Record<string, unknown>).id === "string",
)
.map((el) => ({
...(el as Flight),
price_usd:
typeof el.price_usd === "number"
? el.price_usd
: Number(el.price_usd) || 0,
}));
rememberFlights(flights);
return flights;
}
return [];
} catch {
return [];
}
}
export function formatTime(iso: string): string {
if (!iso || typeof iso !== "string") return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) {
return iso;
}
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function stopsLabel(stops: number): string {
if (stops === 0) return "Nonstop";
if (stops === 1) return "1 stop";
return `${stops} stops`;
}
@@ -0,0 +1,139 @@
"use client";
import { useCallback, useEffect, useState } from "react";
export type Thread = { id: string; title: string; createdAt: number };
const STORAGE_KEY = "oracle-concierge-threads";
/** Title a thread carries until its first user message names it (see ThreadTitler.tsx). */
export const DEFAULT_THREAD_TITLE = "New conversation";
const MAX_TITLE_LEN = 60;
/**
* Derive a thread title from a user's submitted message text. Collapses
* whitespace, trims, and truncates to MAX_TITLE_LEN with an ellipsis. Returns
* null for empty/whitespace-only input (caller leaves the default title).
*/
export function titleFromText(text: string): string | null {
const clean = text.replace(/\s+/g, " ").trim();
if (!clean) return null;
return clean.length > MAX_TITLE_LEN
? `${clean.slice(0, MAX_TITLE_LEN).trimEnd()}`
: clean;
}
function makeThread(title = DEFAULT_THREAD_TITLE): Thread {
const id =
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `t-${Date.now()}-${Math.random().toString(36).slice(2)}`;
return { id, title, createdAt: Date.now() };
}
export function useThreadStore(): {
ready: boolean;
threads: Thread[];
activeThreadId: string;
newThread: () => void;
selectThread: (id: string) => void;
renameThread: (id: string, title: string) => void;
} {
const [threads, setThreads] = useState<Thread[]>([]);
const [activeThreadId, setActiveThreadId] = useState<string>("");
const [ready, setReady] = useState<boolean>(false);
// Mount: hydrate from localStorage, seed if empty. We intentionally setState
// synchronously here — localStorage is client-only, so reading it is deferred to
// this mount effect (after the SSR-safe empty initial render) to avoid a
// hydration mismatch. The single extra mount render is the expected hydration cost.
/* eslint-disable react-hooks/set-state-in-effect -- intentional client-only localStorage hydration on mount */
useEffect(() => {
if (typeof window === "undefined") return;
try {
let valid: Thread[] = [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as unknown;
valid = Array.isArray(parsed)
? parsed.filter(
(t): t is Thread =>
t != null &&
typeof (t as Thread).id === "string" &&
typeof (t as Thread).title === "string" &&
typeof (t as Thread).createdAt === "number",
)
: [];
}
} catch (e) {
console.warn("thread store: failed to parse localStorage", e);
}
if (valid.length > 0) {
setThreads(valid);
setActiveThreadId(valid[0].id);
} else {
const seed = makeThread();
setThreads([seed]);
setActiveThreadId(seed.id);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([seed]));
} catch (e) {
console.warn("thread store: failed to write seed to localStorage", e);
}
}
} catch (e) {
console.warn("thread store init failed", e);
const seed = makeThread();
setThreads([seed]);
setActiveThreadId(seed.id);
} finally {
setReady(true);
}
}, []);
/* eslint-enable react-hooks/set-state-in-effect */
// Persist whenever threads change (after ready)
useEffect(() => {
if (!ready || typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(threads));
} catch (e) {
console.warn(
"thread store: failed to persist threads to localStorage",
e,
);
}
}, [threads, ready]);
const newThread = useCallback(() => {
const t = makeThread();
setThreads((prev) => [t, ...prev]);
setActiveThreadId(t.id);
}, []);
const selectThread = useCallback(
(id: string) => {
if (threads.some((t) => t.id === id)) {
setActiveThreadId(id);
}
},
[threads],
);
const renameThread = useCallback((id: string, title: string) => {
setThreads((prev) =>
prev.map((t) => (t.id === id && t.title !== title ? { ...t, title } : t)),
);
}, []);
return {
ready,
threads,
activeThreadId,
newThread,
selectThread,
renameThread,
};
}
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
File diff suppressed because one or more lines are too long