From 410e84161adecfca6aa180e611e12a2f58db2cb3 Mon Sep 17 00:00:00 2001 From: Rassl Date: Wed, 19 Aug 2026 22:33:07 +0000 Subject: [PATCH] Generated with Hive: Mint and reuse per-thread session id in ontology agent chat panel --- .../admin/ontology/ontology-agent-panel.tsx | 10 +- .../graph-api-ontology-agent.test.ts | 148 ++++++++++++ .../__tests__/ontology-agent-panel.test.tsx | 218 ++++++++++++++++++ src/lib/graph-api.ts | 16 +- 4 files changed, 386 insertions(+), 6 deletions(-) create mode 100644 src/lib/__tests__/graph-api-ontology-agent.test.ts create mode 100644 src/lib/__tests__/ontology-agent-panel.test.tsx diff --git a/src/app/admin/ontology/ontology-agent-panel.tsx b/src/app/admin/ontology/ontology-agent-panel.tsx index 4b5ccca..f613410 100644 --- a/src/app/admin/ontology/ontology-agent-panel.tsx +++ b/src/app/admin/ontology/ontology-agent-panel.tsx @@ -46,6 +46,14 @@ export function OntologyAgentPanel({ onClose }: { onClose: () => void }) { const schemas = useSchemaStore((s) => s.schemas) const [turns, setTurns] = useState([]) const [busy, setBusy] = useState(false) + // One stable session_id per mounted chat thread. This relies on + // OntologyAgentPanel being unmounted/remounted by its parent (OntologyPage + // renders `{showAgent ? : …}`), which naturally resets + // this state on close. If a future "New Chat" action clears `turns` without + // unmounting, it must also explicitly reset (or re-key) this state — otherwise + // the session_id will silently persist across what the user sees as a new + // conversation. + const [sessionId] = useState(() => crypto.randomUUID()) const pollRef = useRef | null>(null) const scrollRef = useRef(null) @@ -127,7 +135,7 @@ export function OntologyAgentPanel({ onClose }: { onClose: () => void }) { ]) try { - const { stakwork_run_ref_id } = await triggerOntologyAgent(instruction, history) + const { stakwork_run_ref_id } = await triggerOntologyAgent({ instruction, history, sessionId }) patchAgentTurn(agentId, { runRef: stakwork_run_ref_id }) startPoll(agentId, stakwork_run_ref_id) } catch { diff --git a/src/lib/__tests__/graph-api-ontology-agent.test.ts b/src/lib/__tests__/graph-api-ontology-agent.test.ts new file mode 100644 index 0000000..9c71d67 --- /dev/null +++ b/src/lib/__tests__/graph-api-ontology-agent.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" + +// Mock sphinx helpers so api.ts can be imported without side-effects +const { getL402Mock, getSignedMessageMock } = vi.hoisted(() => ({ + getL402Mock: vi.fn(), + getSignedMessageMock: vi.fn(), +})) + +vi.mock("@/lib/sphinx", () => ({ + getL402: getL402Mock, + getSignedMessage: getSignedMessageMock, +})) + +// Disable mocks mode so the real API paths are exercised +vi.mock("@/lib/mock-data", () => ({ + isMocksEnabled: () => false, + MOCK_REVIEWS: [], + MOCK_WORKFLOW_MARKETPLACE: [], +})) + +import { triggerOntologyAgent } from "@/lib/graph-api" + +const originalFetch = global.fetch + +beforeEach(() => { + getSignedMessageMock.mockResolvedValue({ signature: "", message: "" }) + getL402Mock.mockResolvedValue(null) +}) + +afterEach(() => { + global.fetch = originalFetch + vi.clearAllMocks() +}) + +describe("triggerOntologyAgent", () => { + it("accepts an options object and POSTs to /v2/schema/ontology-agent", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ stakwork_run_ref_id: "run-42" }), + }) as unknown as typeof fetch + + const result = await triggerOntologyAgent({ + instruction: "Add a Podcast type", + sessionId: "test-session-uuid", + }) + + expect(result).toEqual({ stakwork_run_ref_id: "run-42" }) + const [[url, options]] = (global.fetch as ReturnType).mock.calls + expect(url).toContain("/v2/schema/ontology-agent") + expect((options as RequestInit).method).toBe("POST") + }) + + it("includes session_id in the POST body", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ stakwork_run_ref_id: "run-session" }), + }) as unknown as typeof fetch + + await triggerOntologyAgent({ + instruction: "Add a Podcast type", + sessionId: "stable-session-abc", + }) + + const [[, options]] = (global.fetch as ReturnType).mock.calls + const body = JSON.parse((options as RequestInit).body as string) + expect(body.session_id).toBe("stable-session-abc") + expect(body.instruction).toBe("Add a Podcast type") + }) + + it("includes history in the POST body when provided", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ stakwork_run_ref_id: "run-hist" }), + }) as unknown as typeof fetch + + const history = [{ role: "user" as const, content: "prior message" }] + + await triggerOntologyAgent({ + instruction: "Follow-up instruction", + history, + sessionId: "session-with-history", + }) + + const [[, options]] = (global.fetch as ReturnType).mock.calls + const body = JSON.parse((options as RequestInit).body as string) + expect(body.history).toEqual(history) + expect(body.session_id).toBe("session-with-history") + }) + + it("defaults history to [] when not provided", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ stakwork_run_ref_id: "run-nohistory" }), + }) as unknown as typeof fetch + + await triggerOntologyAgent({ + instruction: "No history here", + sessionId: "session-no-history", + }) + + const [[, options]] = (global.fetch as ReturnType).mock.calls + const body = JSON.parse((options as RequestInit).body as string) + expect(body.history).toEqual([]) + }) + + it("throws on non-2xx response", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({}), + }) as unknown as typeof fetch + + const err = await triggerOntologyAgent({ + instruction: "Will fail", + sessionId: "any-session", + }).catch((e) => e) + + expect(err).toBeDefined() + expect(err.status).toBe(500) + }) +}) + +describe("triggerOntologyAgent (mock mode)", () => { + beforeEach(() => { + vi.resetModules() + }) + + it("returns a mock run ref without calling fetch when mocks are enabled", async () => { + vi.doMock("@/lib/mock-data", () => ({ + isMocksEnabled: () => true, + MOCK_REVIEWS: [], + MOCK_WORKFLOW_MARKETPLACE: [], + })) + + global.fetch = vi.fn() as unknown as typeof fetch + + // Dynamically import so the mock is picked up + const { triggerOntologyAgent: triggerMock } = await import("@/lib/graph-api") + + const result = await triggerMock({ + instruction: "Some instruction", + sessionId: "mock-session", + }) + + expect(result.stakwork_run_ref_id).toMatch(/^mock-ontology-run-/) + expect(global.fetch).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/__tests__/ontology-agent-panel.test.tsx b/src/lib/__tests__/ontology-agent-panel.test.tsx new file mode 100644 index 0000000..9d981be --- /dev/null +++ b/src/lib/__tests__/ontology-agent-panel.test.tsx @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { render, screen, waitFor, act } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import React from "react" + +// ── Hoisted mocks ───────────────────────────────────────────────────────────── + +const { mockTriggerOntologyAgent, mockGetStakworkRun, mockListReviews } = + vi.hoisted(() => ({ + mockTriggerOntologyAgent: vi.fn(), + mockGetStakworkRun: vi.fn(), + mockListReviews: vi.fn(), + })) + +vi.mock("@/lib/graph-api", () => ({ + triggerOntologyAgent: (...args: unknown[]) => mockTriggerOntologyAgent(...args), + getStakworkRun: (...args: unknown[]) => mockGetStakworkRun(...args), + listReviews: (...args: unknown[]) => mockListReviews(...args), +})) + +vi.mock("@/stores/schema-store", () => ({ + useSchemaStore: ( + sel: (s: { schemas: never[]; fetchAll: () => void }) => unknown + ) => sel({ schemas: [], fetchAll: vi.fn() }), +})) + +vi.mock("@/components/admin/review-row", () => ({ + ReviewRow: () =>
, +})) + +// ── DOM stubs ───────────────────────────────────────────────────────────────── +// jsdom doesn't implement scrollTo; stub so the transcript scroll useEffect +// doesn't throw. +Element.prototype.scrollTo = () => {} + +// crypto.randomUUID: counter-based stub — unique per call, no Web Crypto needed. +let _uuidCounter = 0 +Object.defineProperty(globalThis.crypto, "randomUUID", { + configurable: true, + value: () => `test-uuid-${++_uuidCounter}`, +}) + +// ── Imports after mocks ─────────────────────────────────────────────────────── +import { OntologyAgentPanel } from "@/app/admin/ontology/ontology-agent-panel" + +// ── Shared setup / teardown ─────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks() + mockTriggerOntologyAgent.mockResolvedValue({ stakwork_run_ref_id: "run-001" }) + // Default: stays RUNNING so panel keeps busy unless overridden per-test. + mockGetStakworkRun.mockResolvedValue({ ref_id: "run-001", status: "RUNNING" }) + mockListReviews.mockResolvedValue({ reviews: [] }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function renderPanel(onClose = vi.fn()) { + return render() +} + +async function typeAndSubmit( + user: ReturnType, + text: string +) { + const textarea = screen.getByPlaceholderText(/describe an ontology change/i) + await user.type(textarea, text) + await user.keyboard("{Enter}") +} + +// ── session_id is forwarded on first submit ─────────────────────────────────── + +describe("OntologyAgentPanel – session_id forwarded to triggerOntologyAgent", () => { + it("passes a non-empty sessionId on the first submit", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }) + + renderPanel() + await typeAndSubmit(user, "Add a Podcast type") + await waitFor(() => expect(mockTriggerOntologyAgent).toHaveBeenCalledOnce()) + + const { sessionId } = mockTriggerOntologyAgent.mock.calls[0][0] + expect(typeof sessionId).toBe("string") + expect(sessionId.length).toBeGreaterThan(0) + }) + + it("passes the instruction text alongside sessionId", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }) + + renderPanel() + await typeAndSubmit(user, "Add a new edge type") + await waitFor(() => expect(mockTriggerOntologyAgent).toHaveBeenCalledOnce()) + + const args = mockTriggerOntologyAgent.mock.calls[0][0] + expect(args.instruction).toBe("Add a new edge type") + expect(args).toHaveProperty("sessionId") + }) +}) + +// ── session_id is stable within one mounted instance ───────────────────────── +// +// Strategy: drive the poll cycle to COMPLETED so `busy` clears, then submit +// a second message and assert both calls used the same sessionId. +// +// The poll uses `setInterval(async () => {...}, 5000)`. Advancing fake time by +// 5 s once fires the interval callback, but the `await getStakworkRun()` inside +// it is async, so React state updates (setBusy(false)) happen asynchronously. +// We call advanceTimersByTimeAsync twice (same pattern as the deep-research +// polling tests in node-preview-panel.test.tsx) to flush both the interval +// timer and the promise chain inside it. + +describe("OntologyAgentPanel – sessionId stability across submits", () => { + it("reuses the same sessionId for consecutive submits within one instance", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }) + + mockTriggerOntologyAgent + .mockResolvedValueOnce({ stakwork_run_ref_id: "run-A" }) + .mockResolvedValueOnce({ stakwork_run_ref_id: "run-B" }) + + // Poll tick 1 → RUNNING, tick 2 → COMPLETED (clears busy) + mockGetStakworkRun + .mockResolvedValueOnce({ ref_id: "run-A", status: "RUNNING" }) + .mockResolvedValueOnce({ ref_id: "run-A", status: "COMPLETED" }) + mockListReviews.mockResolvedValue({ reviews: [] }) + + renderPanel() + + // ── Submit #1 ────────────────────────────────────────────────────────── + await typeAndSubmit(user, "First instruction") + await waitFor(() => expect(mockTriggerOntologyAgent).toHaveBeenCalledOnce()) + const firstSessionId: string = mockTriggerOntologyAgent.mock.calls[0][0].sessionId + + // Type the second message while the panel is still busy — the Composer's + // text state accepts typing regardless of the busy flag. + const textarea = screen.getByPlaceholderText(/describe an ontology change/i) + await user.type(textarea, "Second instruction") + + // Now drive the poll to completion: two 5-second ticks flush both the + // setInterval callback and the async getStakworkRun chain inside it. + await vi.advanceTimersByTimeAsync(5000) + await vi.advanceTimersByTimeAsync(5000) + + // busy is now false; the send button should be enabled (text is also non-empty) + await waitFor(() => + expect(screen.getByTitle("Send")).not.toBeDisabled() + ) + + // ── Submit #2 — press Enter to send the pre-typed text ──────────────── + await user.keyboard("{Enter}") + await waitFor(() => expect(mockTriggerOntologyAgent).toHaveBeenCalledTimes(2)) + const secondSessionId: string = mockTriggerOntologyAgent.mock.calls[1][0].sessionId + + // Same mounted instance → session_id must be identical. + expect(secondSessionId).toBe(firstSessionId) + } finally { + vi.useRealTimers() + } + }) +}) + +// ── session_id resets on unmount/remount ────────────────────────────────────── + +describe("OntologyAgentPanel – sessionId resets on remount", () => { + it("generates a different sessionId for each new mounted instance", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + // ── First mount ────────────────────────────────────────────────────────── + const user1 = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }) + const { unmount } = renderPanel() + await typeAndSubmit(user1, "First session") + await waitFor(() => expect(mockTriggerOntologyAgent).toHaveBeenCalledOnce()) + const firstSessionId: string = mockTriggerOntologyAgent.mock.calls[0][0].sessionId + + // Unmount simulates the user closing the panel. + unmount() + vi.clearAllMocks() + mockTriggerOntologyAgent.mockResolvedValue({ stakwork_run_ref_id: "run-003" }) + mockGetStakworkRun.mockResolvedValue({ ref_id: "run-003", status: "RUNNING" }) + + // ── Second mount ───────────────────────────────────────────────────────── + const user2 = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }) + renderPanel() + await typeAndSubmit(user2, "Second session") + await waitFor(() => expect(mockTriggerOntologyAgent).toHaveBeenCalledOnce()) + const secondSessionId: string = mockTriggerOntologyAgent.mock.calls[0][0].sessionId + + // A fresh mount must produce a different session id. + expect(secondSessionId).not.toBe(firstSessionId) + } finally { + vi.useRealTimers() + } + }) +}) + +// ── onClose forwarding ──────────────────────────────────────────────────────── + +describe("OntologyAgentPanel – onClose", () => { + it("calls onClose when the X button is clicked", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + const onClose = vi.fn() + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }) + renderPanel(onClose) + + await user.click(screen.getByTitle("Close")) + expect(onClose).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index 23cad39..8b84acc 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -772,11 +772,17 @@ let _mockOntologyRunCounter = 0 // Admin-only, graph-scoped. Triggers the ontology_agent workflow; proposals // come back as reviews (polled via getStakworkRun → listReviews(run_ref_id)). -export async function triggerOntologyAgent( - instruction: string, - history?: OntologyAgentMessage[], +export async function triggerOntologyAgent({ + instruction, + history, + sessionId, + signal, +}: { + instruction: string + history?: OntologyAgentMessage[] + sessionId: string signal?: AbortSignal -): Promise<{ stakwork_run_ref_id: string }> { +}): Promise<{ stakwork_run_ref_id: string }> { if (isMocksEnabled()) { const runRef = `mock-ontology-run-${++_mockOntologyRunCounter}` _mockOntologyPollCounts[runRef] = 0 @@ -787,7 +793,7 @@ export async function triggerOntologyAgent( } return api.post<{ stakwork_run_ref_id: string }>( "/v2/schema/ontology-agent", - { instruction, history: history ?? [] }, + { instruction, history: history ?? [], session_id: sessionId }, undefined, signal )