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 [...]