You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/14 05:52:45 UTC

(skywalking-horizon-ui) branch main updated: fix(ai-conversation): forward the selected cold stage to details (#161)

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new 4c737bdc fix(ai-conversation): forward the selected cold stage to details (#161)
4c737bdc is described below

commit 4c737bdc658bea732515f6394995f27ca3f8b965
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Mon Sep 14 13:52:40 2026 +0800

    fix(ai-conversation): forward the selected cold stage to details (#161)
---
 apps/bff/CLAUDE.md                              |  2 +-
 apps/bff/src/client/ai-conversation.test.ts     | 13 ++++++++++--
 apps/bff/src/client/ai-conversation.ts          |  6 +++++-
 apps/bff/src/http/query/ai-conversation.test.ts | 17 +++++++++++++++
 apps/bff/src/http/query/ai-conversation.ts      |  1 +
 apps/ui/src/api/scopes/ai-conversation.test.ts  | 28 ++++++++++++++++++++++++-
 apps/ui/src/api/scopes/ai-conversation.ts       |  6 +++++-
 docs/changelog/1.1.0.md                         |  2 +-
 8 files changed, 68 insertions(+), 7 deletions(-)

diff --git a/apps/bff/CLAUDE.md b/apps/bff/CLAUDE.md
index 621668c4..d8047522 100644
--- a/apps/bff/CLAUDE.md
+++ b/apps/bff/CLAUDE.md
@@ -113,7 +113,7 @@ it empties it. A Cold toggle would blank the alarm list and the entity pickers.
 | `log.ts`, `browser-errors.ts` — logs | `instance.ts` — the instance picker |
 | `dashboard.ts`, `landing.ts`, `explore.ts` — metrics | `endpoint.ts` — the endpoint picker |
 | `mqe-exec.ts` — one metric expression, run from the template editor | |
-| `ai-conversation.ts` — the conversation LIST (`recordsAIAgent` is a record group with a cold stage) | `ai-conversation.ts` — the conversation VIEW relay: no `Duration` to carry a flag on |
+| `ai-conversation.ts` — the conversation list (`Duration.coldStage`) and view relay (`coldStage=true` query parameter), only when the UI selects cold | |
 
 The conversation VIEW relay holds the whole document before answering (gzip in memory, a fifth of the document's size): OAP streams it without a length, and the page shows "n of m MB" only because the BFF is the first to know `m`. It therefore asks OAP for gzip or plain bytes only — never the browser's own `Accept-Encoding` list — since it decodes what it holds to count it and to read the summary off the head (`logic/ai-conversation/hold-document.ts`). The decoded size and the summary's  [...]
 | `topology.ts`, `deployment.ts`, `instance-topology.ts`, `endpoint-dependency.ts`, `infra-3d-metrics.ts` — metrics | `events.ts` — events live in `records` |
diff --git a/apps/bff/src/client/ai-conversation.test.ts b/apps/bff/src/client/ai-conversation.test.ts
index 251290f8..3122e2c4 100644
--- a/apps/bff/src/client/ai-conversation.test.ts
+++ b/apps/bff/src/client/ai-conversation.test.ts
@@ -125,6 +125,15 @@ describe('listAiConversations', () => {
 });
 
 describe('aiConversationViewPath', () => {
+  it('names the cold stage only when explicitly selected', () => {
+    expect(aiConversationViewPath('c1', 's', undefined, false)).toBe(
+      '/ai-agent/conversations/c1/v1/view?service=s',
+    );
+    expect(aiConversationViewPath('c1', 's', undefined, true)).toBe(
+      '/ai-agent/conversations/c1/v1/view?service=s&coldStage=true',
+    );
+  });
+
   it('is the OAP route with the service, and the sender only when given', () => {
     expect(aiConversationViewPath('c 1', 'Claude Code')).toBe(
       '/ai-agent/conversations/c%201/v1/view?service=Claude+Code',
@@ -173,9 +182,9 @@ describe('openAiConversationView', () => {
     });
     const up = await openAiConversationView(
       { queryUrl: url, timeoutMs: 2000, auth: { username: 'u', password: 'p' } },
-      { conversation: 'c1', serviceName: 'Claude Code', instanceName: 'me@host', format: 'json', acceptEncoding: 'gzip, br' },
+      { conversation: 'c1', serviceName: 'Claude Code', instanceName: 'me@host', coldStage: true, format: 'json', acceptEncoding: 'gzip, br' },
     );
-    expect(path).toBe('/ai-agent/conversations/c1/v1/view?service=Claude+Code&instance=me%40host');
+    expect(path).toBe('/ai-agent/conversations/c1/v1/view?service=Claude+Code&instance=me%40host&coldStage=true');
     expect(seen.accept).toBe('application/vnd.skywalking.asz.view+json');
     expect(seen['accept-encoding']).toBe('gzip, br');
     expect(seen.authorization).toBe(`Basic ${Buffer.from('u:p').toString('base64')}`);
diff --git a/apps/bff/src/client/ai-conversation.ts b/apps/bff/src/client/ai-conversation.ts
index b2774940..bd52c56b 100644
--- a/apps/bff/src/client/ai-conversation.ts
+++ b/apps/bff/src/client/ai-conversation.ts
@@ -155,6 +155,8 @@ export interface AiConversationViewRequest {
   conversation: string;
   serviceName: string;
   instanceName?: string;
+  /** Opt in to BanyanDB's cold stage; omitted or false reads hot and warm. */
+  coldStage?: boolean;
   /** `yaml` asks for the `+yaml` twin; anything else is the JSON document. */
   format: 'json' | 'yaml';
   /** The browser's own `Accept-Encoding`, forwarded verbatim so OAP compresses
@@ -194,9 +196,11 @@ export function aiConversationViewPath(
   conversation: string,
   serviceName: string,
   instanceName?: string,
+  coldStage = false,
 ): string {
   const qs = new URLSearchParams({ service: serviceName });
   if (instanceName) qs.set('instance', instanceName);
+  if (coldStage) qs.set('coldStage', 'true');
   return `/ai-agent/conversations/${encodeURIComponent(conversation)}/v1/view?${qs.toString()}`;
 }
 
@@ -205,7 +209,7 @@ export function openAiConversationView(
   q: AiConversationViewRequest,
 ): Promise<AiConversationViewUpstream> {
   const url = new URL(
-    opts.queryUrl.replace(/\/$/, '') + aiConversationViewPath(q.conversation, q.serviceName, q.instanceName),
+    opts.queryUrl.replace(/\/$/, '') + aiConversationViewPath(q.conversation, q.serviceName, q.instanceName, q.coldStage),
   );
   const lib = url.protocol === 'https:' ? https : http;
   const headers: Record<string, string> = {
diff --git a/apps/bff/src/http/query/ai-conversation.test.ts b/apps/bff/src/http/query/ai-conversation.test.ts
index 3089a767..15324651 100644
--- a/apps/bff/src/http/query/ai-conversation.test.ts
+++ b/apps/bff/src/http/query/ai-conversation.test.ts
@@ -266,6 +266,23 @@ describe('GET /api/ai-conversation/:conversation/view', () => {
   const DOC_GZIP = gzipSync(DOC);
   const JSON_TYPE = 'application/vnd.skywalking.asz.view+json; version=1.0; charset=utf-8';
 
+  it.each([undefined, '0', '1'])('forwards the explicit cold-stage header %s to OAP', async (stage) => {
+    let path = '';
+    const url = await serveOap((req, res) => {
+      path = req.url ?? '';
+      res.writeHead(200, { 'content-type': JSON_TYPE });
+      res.end(DOC);
+    });
+    const { app, cookie: c } = await build(fakeOap().fetch, { oap: { queryUrl: url } });
+    const res = await app.inject({
+      method: 'GET',
+      url: '/api/ai-conversation/c1/view?service=s',
+      headers: { cookie: c, ...(stage === undefined ? {} : { 'x-horizon-cold-stage': stage }) },
+    });
+    expect(res.statusCode).toBe(200);
+    expect(path).toBe(`/ai-agent/conversations/c1/v1/view?service=s${stage === '1' ? '&coldStage=true' : ''}`);
+  });
+
   it('refuses a request that names no service', async () => {
     const { app, cookie: c } = await build(fakeOap().fetch);
     const res = await app.inject({ method: 'GET', url: '/api/ai-conversation/c1/view', headers: { cookie: c } });
diff --git a/apps/bff/src/http/query/ai-conversation.ts b/apps/bff/src/http/query/ai-conversation.ts
index 16689668..807bf5f5 100644
--- a/apps/bff/src/http/query/ai-conversation.ts
+++ b/apps/bff/src/http/query/ai-conversation.ts
@@ -220,6 +220,7 @@ export function registerAiConversationRoutes(app: FastifyInstance, deps: AiConve
             conversation,
             serviceName,
             instanceName: typeof q.instance === 'string' && q.instance ? q.instance : undefined,
+            coldStage: !!req.coldStage,
             format: yaml ? 'yaml' : 'json',
             acceptEncoding: browserTakesGzip ? 'gzip' : 'identity',
           },
diff --git a/apps/ui/src/api/scopes/ai-conversation.test.ts b/apps/ui/src/api/scopes/ai-conversation.test.ts
index 25ca0c65..3570e68e 100644
--- a/apps/ui/src/api/scopes/ai-conversation.test.ts
+++ b/apps/ui/src/api/scopes/ai-conversation.test.ts
@@ -22,6 +22,12 @@ import '../client';
 import { AiConversationApi, AiConversationViewError } from './ai-conversation';
 import type { BffClient } from '../client';
 
+const coldStage = vi.hoisted(() => ({ enabled: false }));
+vi.mock('@/controls/coldStage', () => ({
+  COLD_STAGE_HEADER: 'X-Horizon-Cold-Stage',
+  readColdStageHeader: () => coldStage.enabled,
+}));
+
 function api(): { api: AiConversationApi; unauthorized: ReturnType<typeof vi.fn> } {
   const unauthorized = vi.fn();
   const bff = { handleUnauthorized: unauthorized, request: vi.fn() } as unknown as BffClient;
@@ -38,9 +44,29 @@ function streamOf(parts: string[]): ReadableStream<Uint8Array> {
   });
 }
 
-afterEach(() => vi.unstubAllGlobals());
+afterEach(() => {
+  coldStage.enabled = false;
+  vi.unstubAllGlobals();
+});
 
 describe('bff.aiConversation.view', () => {
+  it('requests cold storage only while the UI selection is enabled', async () => {
+    const fetchMock = vi.fn().mockImplementation(async () => new Response(
+      JSON.stringify({ format: 'asz.view', version: '1.0' }),
+      { status: 200 },
+    ));
+    vi.stubGlobal('fetch', fetchMock);
+    const { api: a } = api();
+    for (const enabled of [false, true, false]) {
+      coldStage.enabled = enabled;
+      await a.view('c1', { service: 's' });
+    }
+    const stages = fetchMock.mock.calls.map(([, init]) =>
+      new Headers((init as RequestInit).headers).get('X-Horizon-Cold-Stage'),
+    );
+    expect(stages).toEqual([null, '1', null]);
+  });
+
   it('asks for the JSON document with the session cookie and reports the bytes as they stream', async () => {
     const doc = { format: 'asz.view', version: '1.0', conversation: 'c1', talks: [] };
     const text = JSON.stringify(doc);
diff --git a/apps/ui/src/api/scopes/ai-conversation.ts b/apps/ui/src/api/scopes/ai-conversation.ts
index 3a946265..0026495b 100644
--- a/apps/ui/src/api/scopes/ai-conversation.ts
+++ b/apps/ui/src/api/scopes/ai-conversation.ts
@@ -27,6 +27,7 @@ import {
 } from '@skywalking-horizon-ui/api-client';
 import type { BffClient } from '../client';
 import { withBase } from '../client';
+import { COLD_STAGE_HEADER, readColdStageHeader } from '@/controls/coldStage';
 import { pushEvent } from '@/controls/eventLog';
 
 /** Why a conversation document could not be read. `not_found` and
@@ -123,7 +124,10 @@ export class AiConversationApi {
     try {
       res = await fetch(withBase(path), {
         credentials: 'include',
-        headers: { accept: ASZ_VIEW_JSON_MEDIA_TYPE },
+        headers: {
+          accept: ASZ_VIEW_JSON_MEDIA_TYPE,
+          ...(readColdStageHeader() ? { [COLD_STAGE_HEADER]: '1' } : {}),
+        },
         ...(opts.signal ? { signal: opts.signal } : {}),
       });
     } catch (err) {
diff --git a/docs/changelog/1.1.0.md b/docs/changelog/1.1.0.md
index dce34ad1..d35b7ce7 100644
--- a/docs/changelog/1.1.0.md
+++ b/docs/changelog/1.1.0.md
@@ -14,7 +14,7 @@
 
 **A new bundled layer, AI Agents, with a Conversations tab.** When OAP reports the `AI_AGENT` layer, the sidebar gains an **AI Agents** entry whose one tab lists the conversations of one agent: pick the agent in the layer header and a time range (a day to 90 days), optionally one agent runtime, click **Run query**, and each row shows the conversation's title, which Sessionizer sent it, its talks and unresolved references, its span and its last activity, newest first. A title text and a c [...]
 
-**A conversation opens in its own tab, as one shareable page.** Clicking a row on the Conversations tab opens `/ai-conversation/<id>` in a new browser tab: the conversation's transcript, with the agent's work folded under each input; a flow timeline of one execution stream with lanes for input, responses, context, model calls, tools, agent activity and nested child streams, relation lines for the selected step, and a way to dive into a child agent and back; and an inspector with the sele [...]
+**A conversation opens in its own tab, as one shareable page.** Clicking a row on the Conversations tab opens `/ai-conversation/<id>` in a new browser tab: the conversation's transcript, with the agent's work folded under each input; a flow timeline of one execution stream with lanes for input, responses, context, model calls, tools, agent activity and nested child streams, relation lines for the selected step, and a way to dive into a child agent and back; and an inspector with the sele [...]
 
 **The AI Agents layer gains metric dashboards for the agent.** OAP now turns Claude Code's own OpenTelemetry metric family into per-agent and per-agent runtime metrics under the `AI_AGENT` layer, whether Claude Code's exporter sends them or the AI Sessionizer derives the token metric from the transcripts it lands. The layer's landing page lists agents with two columns, **Tokens** and **Cache read share**, and the layer gains an **Agents** dashboard and a **Agent runtimes** dashboard. Fou [...]