Add the ai-agent commands for the conversations the AI Sessionizer lands (#234)
diff --git a/CHANGES.md b/CHANGES.md
index 3e7a3a8..5e1abc2 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -7,6 +7,7 @@
### Features
+* Add the `ai-agent` commands, `list`, `files` and `view`, for the AI agent conversations the AI Sessionizer lands in the OAP (11.1.0+); `view` reads the whole conversation as one `asz.view` document from the OAP's streamed route on the GraphQL host by @wu-sheng in https://github.com/apache/skywalking-cli/pull/234
* Add the sub-command `profiling async` for async-profiler query API by @zhengziyi0117 in https://github.com/apache/skywalking-cli/pull/203
* Support the owner in MQE response by using [10.2 MQE query protocol](https://github.com/apache/skywalking-query-protocol/pull/141) by @zhengziyi0117 in https://github.com/apache/skywalking-cli/pull/203
* Add the sub-command `alarm autocomplete-keys` and `alarm auto-complete-values` for alarm query API by @mrproliu in https://github.com/apache/skywalking-cli/pull/210
diff --git a/assets/graphqls/aiagent/ConversationRawFiles.graphql b/assets/graphqls/aiagent/ConversationRawFiles.graphql
new file mode 100644
index 0000000..824e2c8
--- /dev/null
+++ b/assets/graphqls/aiagent/ConversationRawFiles.graphql
@@ -0,0 +1,34 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you under
+# the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# The body is read from storage only when selected; $body is true on the export path.
+query ($condition: ConversationCondition!, $files: [ID!], $body: Boolean!) {
+ result: getConversationRawFiles(condition: $condition, files: $files) {
+ errorReason
+ files {
+ id
+ format
+ session
+ seq
+ round
+ digest
+ bytes
+ timestamp
+ body @include(if: $body)
+ }
+ }
+}
diff --git a/assets/graphqls/aiagent/ListConversations.graphql b/assets/graphqls/aiagent/ListConversations.graphql
new file mode 100644
index 0000000..b7243d7
--- /dev/null
+++ b/assets/graphqls/aiagent/ListConversations.graphql
@@ -0,0 +1,36 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you under
+# the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+query ($condition: ConversationListCondition!, $duration: Duration!) {
+ result: listConversations(condition: $condition, duration: $duration) {
+ errorReason
+ conversations {
+ conversation
+ serviceInstanceId
+ serviceInstanceName
+ title
+ round
+ talks
+ steps
+ streams
+ segments
+ unresolved
+ from
+ to
+ }
+ }
+}
diff --git a/cmd/swctl/main.go b/cmd/swctl/main.go
index 01ee2c9..f35fff1 100644
--- a/cmd/swctl/main.go
+++ b/cmd/swctl/main.go
@@ -23,6 +23,7 @@
"runtime"
"github.com/apache/skywalking-cli/internal/commands/admin"
+ "github.com/apache/skywalking-cli/internal/commands/aiagent"
"github.com/apache/skywalking-cli/internal/commands/alarm"
"github.com/apache/skywalking-cli/internal/commands/browser"
"github.com/apache/skywalking-cli/internal/commands/completion"
@@ -117,6 +118,7 @@
menu.Command,
hierarchy.Command,
admin.Command,
+ aiagent.Command,
}
app.Before = interceptor.BeforeChain(
diff --git a/dist/LICENSE b/dist/LICENSE
index fad46a8..c63e40c 100644
--- a/dist/LICENSE
+++ b/dist/LICENSE
@@ -213,7 +213,7 @@
sigs.k8s.io/controller-runtime v0.20.4 Apache-2.0
sigs.k8s.io/randfill v1.0.0 Apache-2.0
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 Apache-2.0
- skywalking.apache.org/repo/goapi v0.0.0-20251011100214-efff910f2031 Apache-2.0
+ skywalking.apache.org/repo/goapi v0.0.0-20260905021802-699be54ca302 Apache-2.0
========================================================================
Apache-2.0 and BSD-3-Clause licenses
diff --git a/go.mod b/go.mod
index b45a2f0..d9f7872 100644
--- a/go.mod
+++ b/go.mod
@@ -18,7 +18,7 @@
gopkg.in/yaml.v2 v2.4.0
k8s.io/apimachinery v0.33.1
sigs.k8s.io/controller-runtime v0.20.4
- skywalking.apache.org/repo/goapi v0.0.0-20251011100214-efff910f2031
+ skywalking.apache.org/repo/goapi v0.0.0-20260905021802-699be54ca302
)
require (
diff --git a/go.sum b/go.sum
index d664728..ef1ad5c 100644
--- a/go.sum
+++ b/go.sum
@@ -543,5 +543,5 @@
sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
-skywalking.apache.org/repo/goapi v0.0.0-20251011100214-efff910f2031 h1:iMd6gzltWrWOtV3COm0mWydeXhpy7r1vkNlTsm7Co0g=
-skywalking.apache.org/repo/goapi v0.0.0-20251011100214-efff910f2031/go.mod h1:Vj9vINJYsTQASPsbQ1i81YgH8nFC/Xds4GjcXvmRYwM=
+skywalking.apache.org/repo/goapi v0.0.0-20260905021802-699be54ca302 h1:XZ27v0cI1QaSyQK48fJfcvpGyhKUv22ULp2cS3l7snI=
+skywalking.apache.org/repo/goapi v0.0.0-20260905021802-699be54ca302/go.mod h1:tsTLCXFg0zZ1lqr5+7tDMsZvZA+YFUCQe0lRctv23yc=
diff --git a/internal/commands/aiagent/aiagent.go b/internal/commands/aiagent/aiagent.go
new file mode 100644
index 0000000..6eec3df
--- /dev/null
+++ b/internal/commands/aiagent/aiagent.go
@@ -0,0 +1,40 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package aiagent holds the commands for the conversations of long-lived AI agents
+// that the AI Sessionizer (apache/skywalking-ai-sessionizer) lands in the OAP under
+// the AI_AGENT layer: the list page, the raw-file export, and the conversation itself
+// as one asz.view document.
+package aiagent
+
+import (
+ "github.com/urfave/cli/v2"
+)
+
+var Command = &cli.Command{
+ Name: "ai-agent",
+ Usage: "AI agent conversations landed by the AI Sessionizer",
+ UsageText: `The AI Sessionizer collects an agent runtime's transcripts and pushes them to the OAP
+under the AI_AGENT layer. "list" and "files" are GraphQL queries on the "--base-url"
+endpoint; "view" reads the whole conversation as one asz.view document from the OAP's
+streamed route on the same host, GET /ai-agent/conversations/{conversation}/v1/view.`,
+ Subcommands: []*cli.Command{
+ listCommand,
+ filesCommand,
+ viewCommand,
+ },
+}
diff --git a/internal/commands/aiagent/files.go b/internal/commands/aiagent/files.go
new file mode 100644
index 0000000..f3cb293
--- /dev/null
+++ b/internal/commands/aiagent/files.go
@@ -0,0 +1,141 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package aiagent
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ api "skywalking.apache.org/repo/goapi/query"
+
+ "github.com/urfave/cli/v2"
+
+ "github.com/apache/skywalking-cli/internal/commands/interceptor"
+ "github.com/apache/skywalking-cli/internal/flags"
+ "github.com/apache/skywalking-cli/pkg/display"
+ "github.com/apache/skywalking-cli/pkg/display/displayable"
+ "github.com/apache/skywalking-cli/pkg/graphql/aiagent"
+)
+
+var filesCommand = &cli.Command{
+ Name: "files",
+ Usage: "List or export the raw files of a conversation, as the OAP stores them",
+ UsageText: `List every landed file and round of a conversation with its digest and size, or
+export them: "--export DIR" reads each body and writes it to its id path under DIR,
+which gives a storage root that "asz verify" and "asz view" read like the original.
+
+Examples:
+1. The files of a conversation:
+$ swctl ai-agent files --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2
+
+2. Export them all:
+$ swctl ai-agent files --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 --export ./root
+
+3. Export two named files:
+$ swctl ai-agent files --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 \
+ --files 7a3c882e-0dc0-46a0-b814-6613d24b7ac2/streams/main/transcript-20260904T152815.774957000Z-000408.sd \
+ --export ./root`,
+ Flags: flags.Flags(
+ flags.ServiceFlags,
+ flags.InstanceFlags,
+ []cli.Flag{
+ &cli.StringFlag{
+ Name: "conversation",
+ Usage: "`id` of the conversation",
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "files",
+ Usage: "only these file `ids`, comma separated; without it, every file of the conversation",
+ },
+ &cli.StringFlag{
+ Name: "export",
+ Usage: "write each file's body to its id path under this `directory`",
+ },
+ },
+ ),
+ Before: interceptor.BeforeChain(
+ interceptor.ParseService(true),
+ interceptor.ParseInstance(false),
+ ),
+ Action: func(ctx *cli.Context) error {
+ condition := &api.ConversationCondition{
+ Service: &api.ServiceCondition{ServiceName: ctx.String("service-name")},
+ Conversation: ctx.String("conversation"),
+ Instance: instanceCondition(ctx),
+ }
+ var files []string
+ if arg := strings.TrimSpace(ctx.String("files")); arg != "" {
+ files = strings.Split(arg, ",")
+ }
+ exportDir := ctx.String("export")
+
+ raw, err := aiagent.RawFiles(ctx.Context, condition, files, exportDir != "")
+ if err != nil {
+ return err
+ }
+ if raw.ErrorReason != nil && *raw.ErrorReason != "" {
+ return fmt.Errorf("%s", *raw.ErrorReason)
+ }
+ if exportDir == "" {
+ return display.Display(ctx.Context, &displayable.Displayable{Data: raw, Condition: condition})
+ }
+
+ written, err := export(exportDir, raw.Files)
+ if err != nil {
+ return err
+ }
+ return display.Display(ctx.Context, &displayable.Displayable{Data: written, Condition: condition})
+ },
+}
+
+// Exported is one file written by "--export": its id path and size, the body left out.
+type Exported struct {
+ ID string `json:"id"`
+ Path string `json:"path"`
+ Bytes int `json:"bytes"`
+}
+
+// export writes each body to its id path under dir. An id is a relative path inside the
+// Sessionizer's storage root; one that would leave dir is refused.
+func export(dir string, files []*api.ConversationRawFile) ([]Exported, error) {
+ root, err := filepath.Abs(dir)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]Exported, 0, len(files))
+ for _, f := range files {
+ if f.Body == nil {
+ return nil, fmt.Errorf("the OAP returned no body for %s", f.ID)
+ }
+ path := filepath.Join(root, filepath.FromSlash(f.ID))
+ if !strings.HasPrefix(path, root+string(filepath.Separator)) {
+ return nil, fmt.Errorf("refusing to write %s outside %s", f.ID, root)
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(path, []byte(*f.Body), 0o644); err != nil { // #nosec G306 -- a landed file is readable by design
+ return nil, err
+ }
+ out = append(out, Exported{ID: f.ID, Path: path, Bytes: len(*f.Body)})
+ }
+ return out, nil
+}
diff --git a/internal/commands/aiagent/list.go b/internal/commands/aiagent/list.go
new file mode 100644
index 0000000..9523c3c
--- /dev/null
+++ b/internal/commands/aiagent/list.go
@@ -0,0 +1,93 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package aiagent
+
+import (
+ api "skywalking.apache.org/repo/goapi/query"
+
+ "github.com/urfave/cli/v2"
+
+ "github.com/apache/skywalking-cli/internal/commands/interceptor"
+ "github.com/apache/skywalking-cli/internal/flags"
+ "github.com/apache/skywalking-cli/internal/model"
+ "github.com/apache/skywalking-cli/pkg/display"
+ "github.com/apache/skywalking-cli/pkg/display/displayable"
+ "github.com/apache/skywalking-cli/pkg/graphql/aiagent"
+)
+
+var listCommand = &cli.Command{
+ Name: "list",
+ Aliases: []string{"ls"},
+ Usage: "List the conversations of an AI agent service",
+ UsageText: `List the conversations of an AI agent service active in the duration, newest first,
+one row per conversation from its newest round.
+
+Examples:
+1. The conversations of service "Claude Code" in the last 30 minutes:
+$ swctl ai-agent list --service-name "Claude Code"
+
+2. Only those pushed by one Sessionizer, in a day:
+$ swctl ai-agent list --service-name "Claude Code" --instance-name laptop --start 2026-09-01 --end 2026-09-02`,
+ Flags: flags.Flags(
+ flags.DurationFlags,
+ flags.ServiceFlags,
+ flags.InstanceFlags,
+ []cli.Flag{
+ &cli.IntFlag{
+ Name: "limit",
+ Usage: "at most this many rounds are read, newest first, before folding to one row per conversation; 0 for the OAP's default",
+ Value: 0,
+ },
+ },
+ ),
+ Before: interceptor.BeforeChain(
+ interceptor.DurationInterceptor,
+ interceptor.ParseService(true),
+ interceptor.ParseInstance(false),
+ ),
+ Action: func(ctx *cli.Context) error {
+ duration := api.Duration{
+ Start: ctx.String("start"),
+ End: ctx.String("end"),
+ Step: ctx.Generic("step").(*model.StepEnumValue).Selected,
+ }
+ condition := &api.ConversationListCondition{
+ Service: &api.ServiceCondition{ServiceName: ctx.String("service-name")},
+ Instance: instanceCondition(ctx),
+ }
+ if limit := ctx.Int("limit"); limit > 0 {
+ condition.Limit = &limit
+ }
+
+ list, err := aiagent.ListConversations(ctx.Context, condition, duration)
+ if err != nil {
+ return err
+ }
+ return display.Display(ctx.Context, &displayable.Displayable{Data: list, Condition: condition, Duration: duration})
+ },
+}
+
+// instanceCondition names the sender when "--instance-name" (or "--instance-id",
+// resolved to the name by the interceptor) was given.
+func instanceCondition(ctx *cli.Context) *api.InstanceCondition {
+ name := ctx.String("instance-name")
+ if name == "" {
+ return nil
+ }
+ return &api.InstanceCondition{ServiceName: ctx.String("service-name"), InstanceName: name}
+}
diff --git a/internal/commands/aiagent/view.go b/internal/commands/aiagent/view.go
new file mode 100644
index 0000000..2bbb385
--- /dev/null
+++ b/internal/commands/aiagent/view.go
@@ -0,0 +1,81 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package aiagent
+
+import (
+ "io"
+ "os"
+
+ "github.com/urfave/cli/v2"
+
+ "github.com/apache/skywalking-cli/internal/commands/interceptor"
+ "github.com/apache/skywalking-cli/internal/flags"
+ "github.com/apache/skywalking-cli/pkg/aiagent/view"
+)
+
+var viewCommand = &cli.Command{
+ Name: "view",
+ Usage: "Read a whole conversation as one asz.view document",
+ UsageText: `Read the whole conversation, once, as one asz.view 1.0 document from the OAP's route
+GET /ai-agent/conversations/{conversation}/v1/view, on the "--base-url" host. The body
+is streamed to stdout, or to "--output", as it arrives: JSON, or YAML with "--yaml".
+The "--display" option does not apply; the document is printed as the OAP sends it.
+
+Examples:
+1. A conversation as JSON, into a file:
+$ swctl ai-agent view --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 --output conversation.json
+
+2. As YAML, on the terminal:
+$ swctl ai-agent view --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 --yaml`,
+ Flags: flags.Flags(
+ flags.ServiceFlags,
+ flags.InstanceFlags,
+ []cli.Flag{
+ &cli.StringFlag{
+ Name: "conversation",
+ Usage: "`id` of the conversation",
+ Required: true,
+ },
+ &cli.BoolFlag{
+ Name: "yaml",
+ Usage: "ask for the document as YAML instead of JSON",
+ },
+ &cli.StringFlag{
+ Name: "output",
+ Usage: "write the document to this `file` instead of stdout",
+ },
+ },
+ ),
+ Before: interceptor.BeforeChain(
+ interceptor.ParseService(true),
+ interceptor.ParseInstance(false),
+ ),
+ Action: func(ctx *cli.Context) error {
+ var out io.Writer = os.Stdout
+ if path := ctx.String("output"); path != "" {
+ f, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ out = f
+ }
+ _, err := view.Fetch(ctx.Context, ctx.String("conversation"), ctx.String("service-name"), ctx.String("instance-name"), ctx.Bool("yaml"), out)
+ return err
+ },
+}
diff --git a/pkg/aiagent/view/view.go b/pkg/aiagent/view/view.go
new file mode 100644
index 0000000..94546c6
--- /dev/null
+++ b/pkg/aiagent/view/view.go
@@ -0,0 +1,132 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package view fetches the asz.view document of an AI agent conversation from the OAP's
+// GET /ai-agent/conversations/{conversation}/v1/view route. The route lives on the query
+// host beside /graphql, not on the admin host, because the document is what the UI reads;
+// it is streamed, since a long conversation renders to tens of megabytes, so the body is
+// copied through and never held whole.
+package view
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/apache/skywalking-cli/pkg/contextkey"
+ "github.com/apache/skywalking-cli/pkg/transport"
+)
+
+const (
+ // MediaTypeJSON names the document, its version a parameter: the route's default body.
+ MediaTypeJSON = "application/vnd.skywalking.asz.view+json"
+ // MediaTypeYAML is the same document as YAML, chosen by Accept.
+ MediaTypeYAML = "application/vnd.skywalking.asz.view+yaml"
+ // problemType is the route's error body, RFC 9457.
+ problemType = "application/problem+json"
+
+ defaultBaseURL = "http://127.0.0.1:12800/graphql"
+)
+
+// Problem is the route's error: an RFC 9457 problem document carrying the status.
+type Problem struct {
+ Type string `json:"type"`
+ Title string `json:"title"`
+ Status int `json:"status"`
+ Detail string `json:"detail"`
+ URL string `json:"-"`
+}
+
+func (p *Problem) Error() string {
+ if p.Detail != "" {
+ return fmt.Sprintf("%d %s: %s (%s)", p.Status, p.Title, p.Detail, p.URL)
+ }
+ return fmt.Sprintf("%d %s (%s)", p.Status, p.Title, p.URL)
+}
+
+// CoreURL is the root of the query host the route lives on, derived from the GraphQL
+// base URL by dropping its path: http://host:12800/graphql becomes http://host:12800.
+// A base URL that does not parse is returned trimmed, so the error surfaces on the call.
+func CoreURL(baseURL string) string {
+ trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/")
+ u, err := url.Parse(trimmed)
+ if err != nil || u.Host == "" {
+ return trimmed
+ }
+ return u.Scheme + "://" + u.Host
+}
+
+// Path is the route of one conversation.
+func Path(conversation string) string {
+ return "/ai-agent/conversations/" + url.PathEscape(conversation) + "/v1/view"
+}
+
+// Fetch streams the document of the conversation to out and returns the Content-Type
+// it came with. serviceName is required; instanceName narrows the read to one sender.
+// A non-2xx answer is returned as a *Problem when the OAP sent one.
+func Fetch(ctx context.Context, conversation, serviceName, instanceName string, yaml bool, out io.Writer) (string, error) {
+ query := url.Values{"service": {serviceName}}
+ if instanceName != "" {
+ query.Set("instance", instanceName)
+ }
+ full := CoreURL(transport.GetValue(ctx, contextkey.BaseURL{}, defaultBaseURL)) + Path(conversation) + "?" + query.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, http.NoBody)
+ if err != nil {
+ return "", err
+ }
+ if yaml {
+ req.Header.Set("Accept", MediaTypeYAML)
+ } else {
+ req.Header.Set("Accept", MediaTypeJSON)
+ }
+ if authorization := transport.AuthHeader(ctx); authorization != "" {
+ req.Header.Set("Authorization", authorization)
+ }
+
+ resp, err := transport.HTTPClient(ctx).Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ contentType := resp.Header.Get("Content-Type")
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return contentType, readError(resp, full)
+ }
+ _, err = io.Copy(out, resp.Body)
+ return contentType, err
+}
+
+// readError turns a non-2xx response into an error: the problem document when the OAP
+// sent one, otherwise the status and whatever the body says.
+func readError(resp *http.Response, full string) error {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
+ mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
+ if mediaType == problemType {
+ problem := &Problem{URL: full}
+ if json.Unmarshal(body, problem) == nil && problem.Status != 0 {
+ return problem
+ }
+ }
+ return fmt.Errorf("%s: %s: %s", full, resp.Status, strings.TrimSpace(string(body)))
+}
diff --git a/pkg/aiagent/view/view_test.go b/pkg/aiagent/view/view_test.go
new file mode 100644
index 0000000..dadf616
--- /dev/null
+++ b/pkg/aiagent/view/view_test.go
@@ -0,0 +1,130 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package view
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/apache/skywalking-cli/pkg/contextkey"
+)
+
+func TestCoreURL(t *testing.T) {
+ cases := map[string]string{
+ "http://127.0.0.1:12800/graphql": "http://127.0.0.1:12800",
+ "https://oap.example.com/graphql/": "https://oap.example.com",
+ "http://[::1]:12800/graphql": "http://[::1]:12800",
+ "http://oap:12800": "http://oap:12800",
+ "not a url": "not a url",
+ }
+ for in, want := range cases {
+ if got := CoreURL(in); got != want {
+ t.Errorf("CoreURL(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+// server answers the route the way the OAP does: the document as JSON in several flushes or
+// as YAML by Accept, a problem document for anything else, and 418 for a wrong request.
+func server(t *testing.T, document []byte) *httptest.Server {
+ t.Helper()
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/ai-agent/conversations/c 1/v1/view" ||
+ r.URL.Query().Get("service") != "agent" || r.URL.Query().Get("instance") != "sender" {
+ w.WriteHeader(http.StatusTeapot)
+ return
+ }
+ if r.Header.Get("Authorization") != "Basic dTpw" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ switch r.Header.Get("Accept") {
+ case MediaTypeYAML:
+ w.Header().Set("Content-Type", MediaTypeYAML+"; version=1.0")
+ _, _ = w.Write([]byte("format: asz.view\n"))
+ case MediaTypeJSON:
+ w.Header().Set("Content-Type", MediaTypeJSON+"; version=1.0")
+ for i := 0; i < len(document); i += 8192 {
+ end := i + 8192
+ if end > len(document) {
+ end = len(document)
+ }
+ _, _ = w.Write(document[i:end])
+ w.(http.Flusher).Flush()
+ }
+ default:
+ w.Header().Set("Content-Type", "application/problem+json")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"type":"about:blank","title":"Not Found","status":404,"detail":"no round"}`))
+ }
+ }))
+}
+
+func testContext(serverURL string) context.Context {
+ ctx := context.WithValue(context.Background(), contextkey.BaseURL{}, serverURL+"/graphql")
+ ctx = context.WithValue(ctx, contextkey.Username{}, "u")
+ return context.WithValue(ctx, contextkey.Password{}, "p")
+}
+
+func TestFetchStreamsTheDocument(t *testing.T) {
+ document := bytes.Repeat([]byte("{\"format\":\"asz.view\"}\n"), 4096)
+ srv := server(t, document)
+ defer srv.Close()
+
+ var out bytes.Buffer
+ contentType, err := Fetch(testContext(srv.URL), "c 1", "agent", "sender", false, &out)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if contentType != MediaTypeJSON+"; version=1.0" || !bytes.Equal(out.Bytes(), document) {
+ t.Fatalf("content type %q, %d bytes", contentType, out.Len())
+ }
+}
+
+func TestFetchAsksForYAML(t *testing.T) {
+ srv := server(t, nil)
+ defer srv.Close()
+
+ var out bytes.Buffer
+ contentType, err := Fetch(testContext(srv.URL), "c 1", "agent", "sender", true, &out)
+ if err != nil || contentType != MediaTypeYAML+"; version=1.0" || out.String() != "format: asz.view\n" {
+ t.Fatalf("%v, content type %q, body %q", err, contentType, out.String())
+ }
+}
+
+func TestAProblemDocumentIsTheError(t *testing.T) {
+ srv := server(t, nil)
+ defer srv.Close()
+
+ req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+Path("c 1")+"?service=agent&instance=sender", http.NoBody)
+ req.Header.Set("Authorization", "Basic dTpw")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var problem *Problem
+ if err := readError(resp, req.URL.String()); !errors.As(err, &problem) ||
+ problem.Status != 404 || problem.Detail != "no round" || problem.Title != "Not Found" {
+ t.Fatalf("problem: %v", err)
+ }
+}
diff --git a/pkg/graphql/aiagent/conversation.go b/pkg/graphql/aiagent/conversation.go
new file mode 100644
index 0000000..fce0246
--- /dev/null
+++ b/pkg/graphql/aiagent/conversation.go
@@ -0,0 +1,58 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package aiagent wraps the GraphQL queries of ai-agent-conversation.graphqls: the
+// list page and the raw-file export of the conversations the AI Sessionizer lands.
+// The conversation document itself is not a GraphQL query; see pkg/aiagent/view.
+package aiagent
+
+import (
+ "context"
+
+ "github.com/machinebox/graphql"
+ api "skywalking.apache.org/repo/goapi/query"
+
+ "github.com/apache/skywalking-cli/assets"
+ "github.com/apache/skywalking-cli/pkg/graphql/client"
+)
+
+// ListConversations lists one row per conversation of a service active in the duration,
+// newest first, from the newest round's attributes.
+func ListConversations(ctx context.Context, condition *api.ConversationListCondition, duration api.Duration) (api.ConversationList, error) {
+ var response map[string]api.ConversationList
+
+ request := graphql.NewRequest(assets.Read("graphqls/aiagent/ListConversations.graphql"))
+ request.Var("condition", condition)
+ request.Var("duration", duration)
+
+ err := client.ExecuteQuery(ctx, request, &response)
+ return response["result"], err
+}
+
+// RawFiles lists every landed file and round of a conversation as stored, or only the
+// named ones; with body, each file comes verbatim, which is the export path.
+func RawFiles(ctx context.Context, condition *api.ConversationCondition, files []string, body bool) (api.ConversationRawFiles, error) {
+ var response map[string]api.ConversationRawFiles
+
+ request := graphql.NewRequest(assets.Read("graphqls/aiagent/ConversationRawFiles.graphql"))
+ request.Var("condition", condition)
+ request.Var("files", files)
+ request.Var("body", body)
+
+ err := client.ExecuteQuery(ctx, request, &response)
+ return response["result"], err
+}
diff --git a/pkg/graphql/menu/menu.go b/pkg/graphql/menu/menu.go
index b46815c..41b5943 100644
--- a/pkg/graphql/menu/menu.go
+++ b/pkg/graphql/menu/menu.go
@@ -24,12 +24,24 @@
"github.com/apache/skywalking-cli/pkg/graphql/client"
"github.com/machinebox/graphql"
-
- api "skywalking.apache.org/repo/goapi/query"
)
-func GetItems(ctx context.Context) ([]*api.MenuItem, error) {
- var response map[string][]*api.MenuItem
+// Item is one entry of the UI menu the OAP served before 11.0.0. The query protocol
+// retired getItems, so goapi no longer generates the type; the command stays for the
+// older backends and carries the shape itself.
+type Item struct {
+ Title string `json:"title"`
+ Icon *string `json:"icon,omitempty"`
+ Layer string `json:"layer"`
+ Activate bool `json:"activate"`
+ SubItems []*Item `json:"subItems"`
+ Description *string `json:"description,omitempty"`
+ DocumentLink *string `json:"documentLink,omitempty"`
+ I18nKey *string `json:"i18nKey,omitempty"`
+}
+
+func GetItems(ctx context.Context) ([]*Item, error) {
+ var response map[string][]*Item
request := graphql.NewRequest(assets.Read("graphqls/menu/GetItems.graphql"))