chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
+26
@@ -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 & 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"
|
||||
>
|
||||
☰
|
||||
</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
Reference in New Issue
Block a user