Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions openspec/specs/analytics-agent-session/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,20 @@
- **WHEN** 用户执行 `cz-cli analytics-agent session delete`
- **THEN** CLI MUST 在发请求前直接返回 `USAGE_ERROR`
- **且** 错误信息 MUST 明确说明 `session-id` 必填

### Requirement: session 问答提示同一 session 必须串行

同一个 Analytics Agent session 内的问答 MUST 由调用方串行提交;CLI MUST 在 session create 下一步提示与 session run help/输出中明确提示前一个问题完成后才能开始下一个,避免 agent 并行发问导致后端返回 `Analysis failed: Another question is currently being processed, please try again later`。

#### Scenario: session run help 提示同一 session 必须串行

- **WHEN** 用户执行 `cz-cli analytics-agent session run --help`
- **THEN** help 中 MUST 提示同一个 session 内的问答必须串行
- **且** 提示 MUST 明确说明前一个问题完成后才能开始下一个
- **且** 提示 MUST 提醒并行时会收到 `Analysis failed: Another question is currently being processed, please try again later`

#### Scenario: session create 输出下一步时提示串行约束

- **WHEN** 用户执行 `cz-cli analytics-agent session create --domain-id 195 --title 销售诊断`
- **THEN** 输出的下一步提示 MUST 提示同一个 session 内的后续问答必须串行
- **且** 提示 MUST 明确说明前一个问题完成后再发下一个
28 changes: 28 additions & 0 deletions openspec/specs/analytics-agent/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# analytics-agent 规格说明

## Purpose
定义 `cz-cli analytics-agent` 命令族对 Analytics Agent 服务、domain、datasource、session 等 API 的封装要求。

## Requirements
### Requirement: profile service 自动推导 Analytics Agent endpoint

Analytics Agent 命令 MUST 在 active profile 未显式配置 `analysis_agent_endpoint` 时,根据 profile 的 `service` 与 `protocol` 自动推导服务地址。推导规则 MUST 使用规范化后的 service URL 并追加 `/clickzetta-campaign-data`,例如 `service=uat-api.clickzetta.com`、`protocol=https` 推导为 `https://uat-api.clickzetta.com/clickzetta-campaign-data`。显式 `analysis_agent_endpoint` MUST 优先于推导结果;兼容旧配置的 `agent.endpoint` MUST 优先于 service 推导但低于 `analysis_agent_endpoint`。

#### Scenario: 根据 service 自动推导 endpoint

- **WHEN** active profile 仅配置 `service = "uat-api.clickzetta.com"` 与 `protocol = "https"`,未配置 `analysis_agent_endpoint` 或 `agent.endpoint`
- **THEN** 用户执行任意 `cz-cli analytics-agent ...` 命令时
- **AND** CLI MUST 使用 `https://uat-api.clickzetta.com/clickzetta-campaign-data` 作为 Analytics Agent endpoint
- **AND** 不要求用户额外执行 `profile update analysis_agent_endpoint`

#### Scenario: 显式 endpoint 优先于自动推导

- **WHEN** active profile 同时配置 `analysis_agent_endpoint = "https://custom.example/agent"` 与 `service = "uat-api.clickzetta.com"`
- **THEN** CLI MUST 使用 `https://custom.example/agent`
- **AND** 不覆盖或忽略显式配置

#### Scenario: 旧 agent endpoint 优先于自动推导

- **WHEN** active profile 配置 `agent.endpoint = "https://legacy.example/agent"` 且未配置 `analysis_agent_endpoint`
- **THEN** CLI MUST 使用 `https://legacy.example/agent`
- **AND** 不使用 service 推导结果
23 changes: 17 additions & 6 deletions packages/cz-cli/src/commands/analytics-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@ const ANSWER_BUILDER_DSL_HELP = [
" - Always run `answer-builder validate` (dry-run) before create.",
].join("\n")

const SESSION_SERIAL_CONCURRENCY_WARNING = [
"同一个 session 内的问答必须串行:前一个问题完成后才能开始下一个。",
"并行发问会报错:Analysis failed: Another question is currently being processed, please try again later。",
].join(" ")

function stringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return value === undefined ? undefined : [String(value)]
return value.map((item) => String(item))
Expand Down Expand Up @@ -480,13 +485,14 @@ async function resolveAnalyticsContext(argv: Record<string, unknown>): Promise<R
if (!endpoint) {
handledError(
"NO_ANALYSIS_AGENT_ENDPOINT",
"No analysis agent endpoint configured for the active profile. Set profiles.<name>.analysis_agent_endpoint first.",
"No analysis agent endpoint can be resolved for the active profile. Configure profiles.<name>.service or set profiles.<name>.analysis_agent_endpoint explicitly.",
{
format,
extra: {
next_steps: [
"cz-cli profile update <profile> service <service-host>",
"cz-cli profile update <profile> analysis_agent_endpoint <URL>",
"cz-cli profile create <name> ... --analysis-agent-endpoint <URL>",
"cz-cli profile create <name> ... --service <service-host>",
],
},
},
Expand Down Expand Up @@ -792,19 +798,23 @@ async function executeSessionRunCommand(
return
}
logOperation(name, { ok: true, timeMs: Date.now() - t0 })
const guidancePayload = payload && typeof payload === "object" && !Array.isArray(payload)
? { ...(payload as Record<string, unknown>), ai_message: SESSION_SERIAL_CONCURRENCY_WARNING }
: payload
if (!summaryOnly) {
writeRenderedPayload(payload, format, field)
writeRenderedPayload(guidancePayload, format, field)
return
}
const summary = extractSummaryString(payload) ?? extractFinalSummary(payload)
if (summary) {
if (format === "json") {
process.stdout.write(summary + "\n")
success(summary, { format, timeMs: Date.now() - t0, aiMessage: SESSION_SERIAL_CONCURRENCY_WARNING })
} else {
process.stdout.write(renderSummary(summary) + "\n")
process.stderr.write(SESSION_SERIAL_CONCURRENCY_WARNING + "\n")
}
} else {
success(null, { format, timeMs: Date.now() - t0 })
success(null, { format, timeMs: Date.now() - t0, aiMessage: SESSION_SERIAL_CONCURRENCY_WARNING })
}
} catch (err) {
logOperation(name, { ok: false, timeMs: Date.now() - t0 })
Expand Down Expand Up @@ -3256,7 +3266,7 @@ export function registerAnalyticsAgentCommand(cli: Argv<GlobalArgs>): void {
? data
: (data as Record<string, unknown>)?.sessionId ?? (data as Record<string, unknown>)?.id
return id
? `Session created (id=${id}). Ask a question with: cz-cli analytics-agent session run --session-id ${id} --msg "<your question>"`
? `Session created (id=${id}). Ask a question with: cz-cli analytics-agent session run --session-id ${id} --msg "<your question>". ${SESSION_SERIAL_CONCURRENCY_WARNING}`
: undefined
})
},
Expand Down Expand Up @@ -3294,6 +3304,7 @@ export function registerAnalyticsAgentCommand(cli: Argv<GlobalArgs>): void {
.option("timeout-ms", { type: "number", describe: "Polling timeout in milliseconds" })
.option("summary", { type: "boolean", default: false, describe: "Show the final answer instead of the full poll payload" })
.option("body", { type: "string", describe: "Full request body as JSON object" })
.epilogue(SESSION_SERIAL_CONCURRENCY_WARNING)
.check((argv) => {
if (!argv["session-id"] && !argv["domain-id"]) {
throw new Error("--domain-id is required when --session-id is not provided")
Expand Down
13 changes: 11 additions & 2 deletions packages/cz-cli/src/connection/profile-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { readFileSync, mkdirSync, writeFileSync, renameSync, chmodSync } from "n
import { homedir } from "node:os"
import { join, dirname } from "node:path"
import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml"
import { DEFAULT_CONNECTION, type ConnectionConfig, type TokenStore, type AuthToken } from "@clickzetta/sdk"
import { DEFAULT_CONNECTION, toServiceUrl, type ConnectionConfig, type TokenStore, type AuthToken } from "@clickzetta/sdk"

function profilesFile() {
return join(process.env.CLICKZETTA_TEST_HOME || homedir(), ".clickzetta", "profiles.toml")
Expand Down Expand Up @@ -329,12 +329,21 @@ export function readAgentEndpoint(profileName?: string): string | undefined {
return profile.analysis_agent_endpoint
}
const agent = profile.agent as Record<string, unknown> | undefined
return (agent?.endpoint as string) || undefined
if (typeof agent?.endpoint === "string" && agent.endpoint) {
return agent.endpoint
}
return inferAgentEndpoint(profile)
} catch {
return undefined
}
}

function inferAgentEndpoint(profile: Record<string, unknown>): string | undefined {
const service = str(profile.service, "")
if (!service) return undefined
return `${toServiceUrl(service, normalizeProtocol(str(profile.protocol, undefined)))}/clickzetta-campaign-data`
}

/**
* Record how a profile authenticates — but ONLY when it has no `auth_type` yet.
*
Expand Down
36 changes: 36 additions & 0 deletions packages/cz-cli/test/analytics-agent-session-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,42 @@ describe("analytics-agent session delete command", () => {
expect(parsed.error.message).toBe("Missing required argument: session-id")
})

test("session create output warns that follow-up questions must be serial", async () => {
globalThis.fetch = mock(async () => jsonResponse({ success: true, data: "123" })) as typeof fetch

const result = await runAnalyticsCli([
"analytics-agent",
"session",
"create",
"--domain-id",
"195",
"--title",
"销售诊断",
])

expect(result.exitCode).toBe(0)
expect(result.output).toContain("Session created (id=123)")
expect(result.output).toContain("同一个 session 内的问答必须串行")
expect(result.output).toContain("Another question is currently being processed")
})

test("session run help warns that questions in the same session must be serial", () => {
const result = spawnSync(process.execPath, [
"./src/main.ts",
"analytics-agent",
"session",
"run",
"--help",
], {
cwd: process.cwd(),
encoding: "utf-8",
})

expect(result.status).toBe(0)
expect(result.stdout).toContain("同一个 session 内的问答必须串行")
expect(result.stdout).toContain("Another question is currently being processed")
})

test("help is discoverable", () => {
const result = spawnSync(process.execPath, [
"./src/main.ts",
Expand Down
9 changes: 7 additions & 2 deletions packages/cz-cli/test/analytics-agent-session-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ describe("analytics-agent session run", () => {
])

expect(result.exitCode).toBe(0)
expect(JSON.parse(result.output.trim())).toEqual(pollPayload)
expect(JSON.parse(result.output.trim())).toMatchObject({
...pollPayload,
ai_message: expect.stringContaining("同一个 session 内的问答必须串行"),
})
})

test("shows the final-summary output when --summary is set", async () => {
Expand Down Expand Up @@ -145,6 +148,8 @@ describe("analytics-agent session run", () => {
])

expect(result.exitCode).toBe(0)
expect(result.output.trim()).toBe("final answer")
expect(result.output).toContain("final answer")
expect(result.output).toContain("同一个 session 内的问答必须串行")
expect(result.output).toContain("Another question is currently being processed")
})
})
29 changes: 29 additions & 0 deletions packages/cz-cli/test/profile-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe("readAgentEndpoint", () => {
'default_profile = "default"',
"",
"[profiles.default]",
'service = "uat-api.clickzetta.com"',
"[profiles.default.agent]",
'endpoint = "https://legacy-agent.clickzetta.com"',
"",
Expand All @@ -51,4 +52,32 @@ describe("readAgentEndpoint", () => {
const { readAgentEndpoint } = await import(`../src/connection/profile-store.ts?${Date.now()}`)
expect(readAgentEndpoint()).toBe("https://legacy-agent.clickzetta.com")
})

test("infers endpoint from profile service when explicit endpoint is missing", async () => {
writeProfilesToml([
'default_profile = "default"',
"",
"[profiles.default]",
'service = "uat-api.clickzetta.com"',
'protocol = "https"',
"",
].join("\n"))

const { readAgentEndpoint } = await import(`../src/connection/profile-store.ts?${Date.now()}`)
expect(readAgentEndpoint()).toBe("https://uat-api.clickzetta.com/clickzetta-campaign-data")
})

test("infers endpoint from full service URL without duplicating protocol", async () => {
writeProfilesToml([
'default_profile = "default"',
"",
"[profiles.default]",
'service = "http://127.0.0.1:3000/api/"',
'protocol = "https"',
"",
].join("\n"))

const { readAgentEndpoint } = await import(`../src/connection/profile-store.ts?${Date.now()}`)
expect(readAgentEndpoint()).toBe("http://127.0.0.1:3000/api/clickzetta-campaign-data")
})
})
Loading