diff --git a/openspec/specs/analytics-agent-session/spec.md b/openspec/specs/analytics-agent-session/spec.md index 86b6da286..c82e6d61a 100644 --- a/openspec/specs/analytics-agent-session/spec.md +++ b/openspec/specs/analytics-agent-session/spec.md @@ -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 明确说明前一个问题完成后再发下一个 diff --git a/openspec/specs/analytics-agent/spec.md b/openspec/specs/analytics-agent/spec.md new file mode 100644 index 000000000..82dd6f168 --- /dev/null +++ b/openspec/specs/analytics-agent/spec.md @@ -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 推导结果 diff --git a/packages/cz-cli/src/commands/analytics-agent.ts b/packages/cz-cli/src/commands/analytics-agent.ts index a5b66230a..7c42453b1 100644 --- a/packages/cz-cli/src/commands/analytics-agent.ts +++ b/packages/cz-cli/src/commands/analytics-agent.ts @@ -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)) @@ -480,13 +485,14 @@ async function resolveAnalyticsContext(argv: Record): Promise.analysis_agent_endpoint first.", + "No analysis agent endpoint can be resolved for the active profile. Configure profiles..service or set profiles..analysis_agent_endpoint explicitly.", { format, extra: { next_steps: [ + "cz-cli profile update service ", "cz-cli profile update analysis_agent_endpoint ", - "cz-cli profile create ... --analysis-agent-endpoint ", + "cz-cli profile create ... --service ", ], }, }, @@ -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), 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 }) @@ -3256,7 +3266,7 @@ export function registerAnalyticsAgentCommand(cli: Argv): void { ? data : (data as Record)?.sessionId ?? (data as Record)?.id return id - ? `Session created (id=${id}). Ask a question with: cz-cli analytics-agent session run --session-id ${id} --msg ""` + ? `Session created (id=${id}). Ask a question with: cz-cli analytics-agent session run --session-id ${id} --msg "". ${SESSION_SERIAL_CONCURRENCY_WARNING}` : undefined }) }, @@ -3294,6 +3304,7 @@ export function registerAnalyticsAgentCommand(cli: Argv): 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") diff --git a/packages/cz-cli/src/connection/profile-store.ts b/packages/cz-cli/src/connection/profile-store.ts index f455ecd6a..c35290172 100644 --- a/packages/cz-cli/src/connection/profile-store.ts +++ b/packages/cz-cli/src/connection/profile-store.ts @@ -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") @@ -329,12 +329,21 @@ export function readAgentEndpoint(profileName?: string): string | undefined { return profile.analysis_agent_endpoint } const agent = profile.agent as Record | 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 | 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. * diff --git a/packages/cz-cli/test/analytics-agent-session-commands.test.ts b/packages/cz-cli/test/analytics-agent-session-commands.test.ts index 5afb00190..d76ebdd32 100644 --- a/packages/cz-cli/test/analytics-agent-session-commands.test.ts +++ b/packages/cz-cli/test/analytics-agent-session-commands.test.ts @@ -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", diff --git a/packages/cz-cli/test/analytics-agent-session-run.test.ts b/packages/cz-cli/test/analytics-agent-session-run.test.ts index 16dc641ef..b8efa662e 100644 --- a/packages/cz-cli/test/analytics-agent-session-run.test.ts +++ b/packages/cz-cli/test/analytics-agent-session-run.test.ts @@ -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 () => { @@ -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") }) }) diff --git a/packages/cz-cli/test/profile-store.test.ts b/packages/cz-cli/test/profile-store.test.ts index 1d257c35c..95cabcab3 100644 --- a/packages/cz-cli/test/profile-store.test.ts +++ b/packages/cz-cli/test/profile-store.test.ts @@ -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"', "", @@ -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") + }) })