diff --git a/docs/en/docs/how-to/configure-dsh.md b/docs/en/docs/how-to/configure-dsh.md index da5486c95..d6b6ab70d 100644 --- a/docs/en/docs/how-to/configure-dsh.md +++ b/docs/en/docs/how-to/configure-dsh.md @@ -36,6 +36,35 @@ workspace therefore uses the Server default instead of the Harness process direc The plugin calls `POST /v1/context/prepare` once before the model analyzes the prompt. Explicit `remember_memory` calls do not require a model. +## Diagnose direct tool and command failures + +Named tools and Scope-dependent `/pc` commands return a controlled failure if Scope resolution fails. They stop before +the requested operation, without creating a binding or retrying with another Scope. Cancellation and the existing +per-request timeout also apply to Scope resolution. + +Inside DeepSeek Harness: + +- `/pc doctor` checks liveness and readiness independently of Scope resolution and reports both results. +- `/pc capabilities` queries the Server capabilities without resolving a Scope. +- Unknown subcommands and missing arguments return local usage help without contacting the Server. +- Bare `/pc` shows the resolved Scope and Server origin. If resolution fails, it returns an error while still showing + `scope=unresolved`, a controlled error, and the `/pc doctor` recovery hint. Configured Scope IDs are not reported as + resolved. The displayed origin omits credentials, paths, query strings, and fragments. +- `search`, `remember`, `flush`, `review`, `skills scan`, and `stats` require a resolved Scope. `stats` queries that Scope. + +| Result code | Meaning | +| --- | --- | +| `not_found` | A business 404. The optional `error_code` preserves a recognized public reason, such as `scope_not_found` or `memory_not_found`. | +| `version_mismatch` | A required endpoint returned 404 without a business code. Check the Server endpoint and plugin/Server compatibility; this does not establish a particular deployment cause. | +| `authentication_failed` | The Server returned 401. Check the configured Authorization header. | +| `unavailable` | Connection failure, timeout, cancellation, or HTTP 503. Native diagnostics use `server_unavailable`. | +| `unscoped` | The resolver completed without a Scope. | +| `invalid_response` | The client detected an invalid Server response. | + +Existing conflict and validation codes, such as `revision_conflict` and `invalid_request`, retain their meaning. Failure +results preserve available HTTP status and request ID, but use fixed messages instead of Server-provided text. Unknown +error codes are omitted from `error_code` and diagnostics; their presence alone does not imply a version mismatch. + ## Control prompt capture Prompt capture is enabled by default. Disable it before starting DeepSeek Harness when the current work must not be recorded: diff --git a/docs/zh/docs/how-to/configure-dsh.md b/docs/zh/docs/how-to/configure-dsh.md index 3bdc498dc..80228809d 100644 --- a/docs/zh/docs/how-to/configure-dsh.md +++ b/docs/zh/docs/how-to/configure-dsh.md @@ -36,6 +36,33 @@ Server 管理的 Scope。workspace 路径只会哈希为外部 binding key。缺 插件在模型分析提示词前只调用一次 `POST /v1/context/prepare`。显式 `remember_memory` 不需要模型。 +## 排查工具和命令的直接调用失败 + +Scope 解析失败时,具名工具和依赖 Scope 的 `/pc` 命令会返回受控失败,并在执行请求的操作前停止。 +插件不会因此创建 binding 或换用其他 Scope 重试。取消信号和现有的单请求超时也适用于 Scope 解析。 + +在 DeepSeek Harness 内: + +- `/pc doctor` 不依赖 Scope 解析,继续检查 liveness 和 readiness,并保留两个检查结果。 +- `/pc capabilities` 直接查询 Server 能力,无需解析 Scope。 +- 未知子命令或缺少参数时,在本地返回用法说明,不访问 Server。 +- 裸 `/pc` 显示已解析的 Scope 和 Server origin。解析失败时返回错误,但仍显示 `scope=unresolved`、受控错误信息 + 和 `/pc doctor` 恢复提示。配置中的 Scope ID 不会被当作已解析成功;显示的 origin 不包含凭据、路径、查询参数和 fragment。 +- `search`、`remember`、`flush`、`review`、`skills scan` 和 `stats` 必须成功解析 Scope;`stats` 仅查询当前 Scope。 + +| 结果 code | 含义 | +| --- | --- | +| `not_found` | 业务 404。可选的 `error_code` 保留已识别的公开原因,例如 `scope_not_found` 或 `memory_not_found`。 | +| `version_mismatch` | 必需端点返回了没有业务码的 404。应检查 Server 端点和插件、Server 的兼容性;该结果不能证明具体的部署原因。 | +| `authentication_failed` | Server 返回 401,应检查 Authorization 配置。 | +| `unavailable` | 连接失败、超时、取消或 HTTP 503。原生诊断使用 `server_unavailable`。 | +| `unscoped` | resolver 执行完成,但没有返回 Scope。 | +| `invalid_response` | 客户端识别到无效的 Server 响应。 | + +已有冲突和校验错误码(如 `revision_conflict`、`invalid_request`)保持原有含义。失败结果保留可用的 HTTP status 和 +request ID,提示文字使用固定内容,不透传 Server message。未知错误码不会出现在 `error_code` 或诊断中, +也不会仅因无法识别就被判为版本不匹配。 + ## 控制提示词采集 默认开启提示词采集。如果当前工作不应被记录,请在启动 DeepSeek Harness 前关闭: diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index e527d310a..a309eca3a 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -70,7 +70,7 @@ var ServerResponseError = class extends ClientError { code; serverMessage; constructor(options) { - const suffix = options.code ? ` (${options.code})` : ""; + const suffix = typeof options.code === "string" ? ` (${options.code})` : ""; super(`PowerContext Server returned HTTP ${options.statusCode}${suffix}`, options.requestId); this.statusCode = options.statusCode; this.path = options.path ?? ""; @@ -775,6 +775,136 @@ function requireService(ctx, name$1) { return service; } +//#endregion +//#region src/diagnostics.ts +const COMPATIBILITY_OR_AVAILABILITY_PATHS = new Set([ + "/health/live", + "/health/ready", + "/v1/capabilities", + "/v1/context/prepare", + "/v1/scope-bindings/resolve" +]); +const PUBLIC_ERROR_CODES = new Set([ + "not_found", + "scope_not_found", + "memory_not_found", + "artifact_not_found", + "candidate_not_found", + "handoff_evidence_not_found", + "source_definition_not_found", + "external_skill_not_found", + "conflict", + "revision_conflict", + "memory_entry_inactive", + "source_conflict", + "candidate_conflict", + "artifact_conflict", + "candidate_terminal", + "scope_version_conflict", + "scope_idempotency_conflict", + "artifact_publication_conflict", + "connector_checkpoint_conflict", + "generation_conflict", + "external_skill_snapshot_unavailable", + "handoff_report_inconsistent", + "invalid_request", + "invalid_scope_relationship", + "invalid_source_ingestion", + "invalid_lifecycle", + "artifact_publication_unsupported", + "capability_not_supported", + "unauthorized", + "forbidden", + "authentication_failed", + "runtime_not_ready", + "generation_unavailable", + "inference_timeout", + "inference_unavailable", + "handoff_generation_unavailable", + "external_skill_registry_unavailable", + "handoff_report_unavailable", + "handoff_report_too_large", + "invalid_handoff_generation", + "remote_skill_distribution_error", + "invalid_target_credential", + "invalid_enrollment", + "invalid_target_state", + "publication_generation_conflict", + "invalid_skill_lifecycle", + "internal_error" +]); +function publicErrorCode(code) { + return typeof code === "string" && PUBLIC_ERROR_CODES.has(code) ? code : void 0; +} +function isVersionMismatch(error) { + return error.statusCode === 404 && error.code === void 0 && COMPATIBILITY_OR_AVAILABILITY_PATHS.has(error.path); +} +const AUTOMATIC_OPERATION_PATHS = new Map([ + ["context_prepare", "/v1/context/prepare"], + ["capture_content_source", "/v1/sources/content"], + ["flush_memory", "/v1/memory/flush"] +]); +function responseDiagnostic(event, outcome, error) { + const code = publicErrorCode(error.code); + return { + event, + outcome, + http_status: error.statusCode, + ...code ? { error_code: code } : {} + }; +} +function isDomainStatus(status) { + return status === 404 || status === 409 || status === 422; +} +function failureEvent(event, error) { + if (error instanceof ServerResponseError) { + if (error.statusCode === 401) return responseDiagnostic(event, "authentication_failed", error); + if (isVersionMismatch(error)) return responseDiagnostic(event, "version_mismatch", error); + if (error.statusCode === 503) return { + ...responseDiagnostic(event, "server_unavailable", error), + recovery: "powercontext doctor" + }; + if (isDomainStatus(error.statusCode) && AUTOMATIC_OPERATION_PATHS.get(event) !== error.path) return void 0; + return responseDiagnostic(event, "invalid_response", error); + } + if (error instanceof TransportError) return { + event, + outcome: "server_unavailable", + recovery: "powercontext doctor" + }; + if (error instanceof InvalidResponseError) return { + event, + outcome: "invalid_response" + }; + return { + event, + outcome: "invalid_response" + }; +} +function createDiagnosticEmitter(write, now = Date.now, cooldownMs = 6e4) { + const lastEmitted = /* @__PURE__ */ new Map(); + return (event) => { + const outcome = typeof event.outcome === "string" ? event.outcome : void 0; + const normalized = { + ...event, + ...outcome === "server_unavailable" && event.recovery === void 0 ? { recovery: "powercontext doctor" } : {} + }; + if (outcome && ![ + "ready", + "ok", + "empty", + "skipped" + ].includes(outcome)) { + const key = outcome; + const timestamp = now(); + const previous = lastEmitted.get(key); + if (previous !== void 0 && timestamp - previous < cooldownMs) return; + lastEmitted.set(key, timestamp); + } + write(JSON.stringify(normalized)); + }; +} + //#endregion //#region src/secrets.ts const SECRET_MARKERS = [ @@ -803,6 +933,7 @@ function toolResultSchema() { required: true }, code: { type: "string" }, + error_code: { type: "string" }, message: { type: "string" }, status: { type: "number" }, request_id: { type: "string" }, @@ -820,6 +951,7 @@ function renderToolResult(_args, value) { }]; } function mapServerError(error) { + const code = publicErrorCode(error.code); if (error.statusCode === 401) return { ok: false, code: "authentication_failed", @@ -827,24 +959,34 @@ function mapServerError(error) { status: 401, request_id: error.requestId }; - if (error.statusCode === 404) return { - ok: false, - code: "not_found", - message: error.serverMessage ?? "PowerContext resource was not found.", - status: 404, - request_id: error.requestId - }; + if (error.statusCode === 404) { + if (isVersionMismatch(error)) return { + ok: false, + code: "version_mismatch", + message: "A required PowerContext endpoint is unavailable. Check the Server endpoint and compatible plugin/Server versions.", + status: 404, + request_id: error.requestId + }; + return { + ok: false, + code: "not_found", + ...code ? { error_code: code } : {}, + message: code === "scope_not_found" ? "PowerContext could not resolve the requested Scope. Check its configuration." : "PowerContext resource was not found.", + status: 404, + request_id: error.requestId + }; + } if (error.statusCode === 409) return { ok: false, - code: error.code ?? "conflict", - message: error.serverMessage ?? "citation conflict; refresh and retry once.", + code: code ?? "conflict", + message: "PowerContext operation conflicts with the current state. Inspect the current reference before retrying.", status: 409, request_id: error.requestId }; if (error.statusCode === 422) return { ok: false, - code: error.code ?? "invalid_request", - message: error.serverMessage ?? "PowerContext rejected the request.", + code: code ?? "invalid_request", + message: "PowerContext rejected the request.", status: 422, request_id: error.requestId }; @@ -857,7 +999,7 @@ function mapServerError(error) { }; return { ok: false, - code: error.code ?? "server_error", + code: code ?? "server_error", message: "PowerContext is unavailable, continue the task.", status: error.statusCode, request_id: error.requestId @@ -875,6 +1017,12 @@ function toToolResult(error) { message: error.message }; if (error instanceof ServerResponseError) return mapServerError(error); + if (error instanceof InvalidResponseError) return { + ok: false, + code: "invalid_response", + message: "PowerContext returned an invalid response.", + request_id: error.requestId + }; if (error instanceof TransportError) return { ok: false, code: "unavailable", @@ -920,18 +1068,29 @@ function encodeSuccess(result) { data: result.value }; } -async function invokeOperation(client, operationId, payload, scopeId, signal) { +async function invokeOperation(client, operationId, payload, scopeId, signal, onFailure) { if (!(operationId in OPERATIONS)) return toToolResult(new UnknownOperationError(operationId)); const id = operationId; const body = injectScope(id, payload, scopeId); if (WRITE_OPS.has(id) && typeof body?.text === "string" && containsSecret(body.text)) return toToolResult(new SecretRejectedError()); if (WRITE_OPS.has(id) && typeof body?.content === "string" && containsSecret(body.content)) return toToolResult(new SecretRejectedError()); try { + if (signal?.aborted) throw new TransportError("", signal.reason); return encodeSuccess(await client.request(id, body, signal)); } catch (error) { + try { + await onFailure?.(error); + } catch {} return toToolResult(error); } } +async function reportDirectFailure(runtime, event, error) { + try { + const diagnostic = failureEvent(event, error); + if (diagnostic) await runtime.log(diagnostic); + } catch {} + return toToolResult(error); +} //#endregion //#region src/scope.ts @@ -947,12 +1106,12 @@ function workspaceBindingKey(cwd) { external_id: createHash("sha256").update(resolve(cwd)).digest("hex") }; } -async function resolveScopeId(client, cwd, configuredScopeId) { +async function resolveScopeId(client, cwd, configuredScopeId, signal) { const workspace = sessionCwd(cwd); const value = (await client.request("resolve_scope_binding", { explicit_scope_id: configuredScopeId, binding_keys: workspace ? [workspaceBindingKey(workspace)] : [] - })).value; + }, signal)).value; const scopeId = value && typeof value === "object" ? value.scope_id : void 0; return typeof scopeId === "string" && scopeId.trim() ? scopeId : void 0; } @@ -968,12 +1127,22 @@ function asResult(result) { text: formatResult(result) }; } -async function call(runtime, scopeId, operationId, payload, signal) { - return asResult(await invokeOperation(runtime.client, operationId, payload, scopeId, signal)); +async function call(runtime, cwd, operationId, payload, signal) { + try { + const scopeId = await runtime.resolveScope(cwd, signal); + if (!scopeId) return asResult({ + ok: false, + code: "unscoped", + message: UNSCOPED_MESSAGE + }); + return asResult(await invokeOperation(runtime.client, operationId, payload, scopeId, signal, (error) => reportDirectFailure(runtime, "command", error))); + } catch (error) { + return asResult(await reportDirectFailure(runtime, "command", error)); + } } -async function handleReview(tokens, runtime, scopeId, signal) { +async function handleReview(tokens, runtime, cwd, signal) { const action = tokens[1]; - if (!action) return call(runtime, scopeId, "list_artifact_candidates", { status: "pending" }, signal); + if (!action) return call(runtime, cwd, "list_artifact_candidates", { status: "pending" }, signal); if (action === "approve") { const candidateId = tokens[2]; const version = Number(tokens[3]); @@ -981,7 +1150,7 @@ async function handleReview(tokens, runtime, scopeId, signal) { kind: "error", text: "Usage: /pc review approve " }; - return call(runtime, scopeId, "approve_artifact_candidate", { + return call(runtime, cwd, "approve_artifact_candidate", { candidate_id: candidateId, expected_version: version }, signal); @@ -994,7 +1163,7 @@ async function handleReview(tokens, runtime, scopeId, signal) { kind: "error", text: "Usage: /pc review reject " }; - return call(runtime, scopeId, "reject_artifact_candidate", { + return call(runtime, cwd, "reject_artifact_candidate", { candidate_id: candidateId, expected_version: version, reason @@ -1006,8 +1175,9 @@ async function handleReview(tokens, runtime, scopeId, signal) { }; } async function handleDoctor(runtime, signal) { - const live = await invokeOperation(runtime.client, "get_liveness", {}, runtime.config.scopeId ?? "", signal); - const ready = await invokeOperation(runtime.client, "get_readiness", {}, runtime.config.scopeId ?? "", signal); + const onFailure = (error) => reportDirectFailure(runtime, "command", error); + const live = await invokeOperation(runtime.client, "get_liveness", {}, "", signal, onFailure); + const ready = await invokeOperation(runtime.client, "get_readiness", {}, "", signal, onFailure); return { kind: live.ok && ready.ok ? "success" : "error", text: formatResult({ @@ -1019,13 +1189,29 @@ async function handleDoctor(runtime, signal) { }) }; } -async function handlePcCommand(rawInput, runtime, scopeId, signal) { +function statusResult(runtime, scopeId, failure) { + let endpoint = "(invalid URL)"; + try { + endpoint = new URL(runtime.config.baseUrl).origin; + } catch {} + return { + kind: failure ? "error" : "success", + text: `scope=${scopeId ?? "unresolved"}\nbaseUrl=${endpoint}\nUse /pc doctor to check Server readiness.` + (failure ? `\n${formatResult(failure)}` : "") + }; +} +async function handlePcCommand(rawInput, runtime, cwd, signal) { const tokens = rawInput.trim().split(/\s+/).filter(Boolean); const command = tokens[0]; - if (!command) return { - kind: "success", - text: `scope=${scopeId}\nbaseUrl=${runtime.config.baseUrl}\nUse /pc doctor to check Server readiness.` - }; + if (!command) try { + const scopeId = await runtime.resolveScope(cwd, signal); + return statusResult(runtime, scopeId, scopeId ? void 0 : { + ok: false, + code: "unscoped", + message: UNSCOPED_MESSAGE + }); + } catch (error) { + return statusResult(runtime, void 0, await reportDirectFailure(runtime, "command", error)); + } if (command === "doctor") return handleDoctor(runtime, signal); if (command === "search") { const query = tokens.slice(1).join(" "); @@ -1033,7 +1219,7 @@ async function handlePcCommand(rawInput, runtime, scopeId, signal) { kind: "error", text: "Usage: /pc search " }; - return call(runtime, scopeId, "search_memory", { + return call(runtime, cwd, "search_memory", { query, limit: 8, mode: "auto" @@ -1045,22 +1231,22 @@ async function handlePcCommand(rawInput, runtime, scopeId, signal) { kind: "error", text: "Usage: /pc remember " }; - return call(runtime, scopeId, "remember_memory", { + return call(runtime, cwd, "remember_memory", { kind: "agent-note", text }, signal); } - if (command === "flush") return call(runtime, scopeId, "flush_memory", {}, signal); - if (command === "review") return handleReview(tokens, runtime, scopeId, signal); + if (command === "flush") return call(runtime, cwd, "flush_memory", {}, signal); + if (command === "review") return handleReview(tokens, runtime, cwd, signal); if (command === "skills") { - if (tokens[1] === "scan") return call(runtime, scopeId, "scan_external_skills", {}, signal); + if (tokens[1] === "scan") return call(runtime, cwd, "scan_external_skills", {}, signal); return { kind: "error", text: "Usage: /pc skills scan" }; } - if (command === "stats") return call(runtime, scopeId, "get_stats", {}, signal); - if (command === "capabilities") return call(runtime, scopeId, "get_capabilities", {}, signal); + if (command === "stats") return call(runtime, cwd, "get_stats", {}, signal); + if (command === "capabilities") return asResult(await invokeOperation(runtime.client, "get_capabilities", {}, "", signal, (error) => reportDirectFailure(runtime, "command", error))); return { kind: "error", text: "Unknown /pc subcommand. Try doctor, search, remember, flush, review, stats, capabilities, skills scan." @@ -1070,14 +1256,7 @@ function registerCommands(ctx, runtime) { requireService(ctx, "commands").register({ name: "pc", description: "PowerContext status, search, review, and diagnostics", - handler: async (invocation) => { - const scopeId = await runtime.resolveScope(invocation.agent.session.header.cwd); - if (!scopeId) return { - kind: "error", - text: UNSCOPED_MESSAGE - }; - return handlePcCommand(invocation.rawInput, runtime, scopeId, invocation.signal); - } + handler: async (invocation) => handlePcCommand(invocation.rawInput, runtime, invocation.agent.session.header.cwd, invocation.signal) }); } @@ -1137,79 +1316,6 @@ function resolveConfig(config = {}, env = process.env) { }; } -//#endregion -//#region src/diagnostics.ts -const COMPATIBILITY_OR_AVAILABILITY_PATHS = new Set([ - "/health/live", - "/health/ready", - "/v1/capabilities", - "/v1/context/prepare" -]); -const AUTOMATIC_OPERATION_PATHS = new Map([ - ["context_prepare", "/v1/context/prepare"], - ["capture_content_source", "/v1/sources/content"], - ["flush_memory", "/v1/memory/flush"] -]); -function responseDiagnostic(event, outcome, error) { - return { - event, - outcome, - http_status: error.statusCode, - ...error.code ? { error_code: error.code } : {} - }; -} -function isDomainStatus(status) { - return status === 404 || status === 409 || status === 422; -} -function failureEvent(event, error) { - if (error instanceof ServerResponseError) { - if (error.statusCode === 401) return responseDiagnostic(event, "authentication_failed", error); - if (error.statusCode === 404 && COMPATIBILITY_OR_AVAILABILITY_PATHS.has(error.path) && error.code === void 0) return responseDiagnostic(event, "version_mismatch", error); - if (error.statusCode === 503) return { - ...responseDiagnostic(event, "server_unavailable", error), - recovery: "powercontext doctor" - }; - if (isDomainStatus(error.statusCode) && AUTOMATIC_OPERATION_PATHS.get(event) !== error.path) return void 0; - return responseDiagnostic(event, "invalid_response", error); - } - if (error instanceof TransportError) return { - event, - outcome: "server_unavailable", - recovery: "powercontext doctor" - }; - if (error instanceof InvalidResponseError) return { - event, - outcome: "invalid_response" - }; - return { - event, - outcome: "invalid_response" - }; -} -function createDiagnosticEmitter(write, now = Date.now, cooldownMs = 6e4) { - const lastEmitted = /* @__PURE__ */ new Map(); - return (event) => { - const outcome = typeof event.outcome === "string" ? event.outcome : void 0; - const normalized = { - ...event, - ...outcome === "server_unavailable" && event.recovery === void 0 ? { recovery: "powercontext doctor" } : {} - }; - if (outcome && ![ - "ready", - "ok", - "empty", - "skipped" - ].includes(outcome)) { - const key = outcome; - const timestamp = now(); - const previous = lastEmitted.get(key); - if (previous !== void 0 && timestamp - previous < cooldownMs) return; - lastEmitted.set(key, timestamp); - } - write(JSON.stringify(normalized)); - }; -} - //#endregion //#region src/peers.ts function profileNodeModulesDir(env = process.env) { @@ -1554,13 +1660,17 @@ function citationParam(description) { }; } async function run(runtime, exec, operationId, payload) { - const scopeId = await runtime.resolveScope(sessionCwd(exec.agent?.session.header.cwd)); - if (!scopeId) return { - ok: false, - code: "unscoped", - message: UNSCOPED_MESSAGE - }; - return invokeOperation(runtime.client, operationId, payload, scopeId, exec.signal); + try { + const scopeId = await runtime.resolveScope(sessionCwd(exec.agent?.session.header.cwd), exec.signal); + if (!scopeId) return { + ok: false, + code: "unscoped", + message: UNSCOPED_MESSAGE + }; + return await invokeOperation(runtime.client, operationId, payload, scopeId, exec.signal, (error) => reportDirectFailure(runtime, "tool_call", error)); + } catch (error) { + return reportDirectFailure(runtime, "tool_call", error); + } } function present(title, kind) { return (args) => ({ @@ -2030,7 +2140,7 @@ function createRuntime(ctx, config) { return { client, config: resolved, - resolveScope: (cwd) => resolveScopeId(client, cwd, resolved.scopeId), + resolveScope: (cwd, signal) => resolveScopeId(client, cwd, resolved.scopeId, signal), log: (event) => { const line = JSON.stringify({ component: "powercontext.dsh", diff --git a/integrations/dsh/plugins/powercontext/src/commands.ts b/integrations/dsh/plugins/powercontext/src/commands.ts index 597fdefd0..c2765b59f 100644 --- a/integrations/dsh/plugins/powercontext/src/commands.ts +++ b/integrations/dsh/plugins/powercontext/src/commands.ts @@ -16,7 +16,7 @@ import type { JsonObject } from './client.ts' import { requireService } from './dsh-service.ts' -import { invokeOperation, type PluginRuntime, type ToolResult } from './invoke.ts' +import { invokeOperation, reportDirectFailure, type PluginRuntime, type ToolResult } from './invoke.ts' import { UNSCOPED_MESSAGE } from './scope.ts' export interface CommandResult { @@ -34,29 +34,36 @@ function asResult(result: ToolResult): CommandResult { async function call( runtime: PluginRuntime, - scopeId: string, + cwd: string | undefined, operationId: string, payload: JsonObject, signal?: AbortSignal, ): Promise { - return asResult(await invokeOperation(runtime.client, operationId, payload, scopeId, signal)) + try { + const scopeId = await runtime.resolveScope(cwd, signal) + if (!scopeId) return asResult({ ok: false, code: 'unscoped', message: UNSCOPED_MESSAGE }) + return asResult(await invokeOperation(runtime.client, operationId, payload, scopeId, signal, + error => reportDirectFailure(runtime, 'command', error))) + } catch (error) { + return asResult(await reportDirectFailure(runtime, 'command', error)) + } } async function handleReview( tokens: string[], runtime: PluginRuntime, - scopeId: string, + cwd: string | undefined, signal?: AbortSignal, ): Promise { const action = tokens[1] - if (!action) return call(runtime, scopeId, 'list_artifact_candidates', { status: 'pending' }, signal) + if (!action) return call(runtime, cwd, 'list_artifact_candidates', { status: 'pending' }, signal) if (action === 'approve') { const candidateId = tokens[2] const version = Number(tokens[3]) if (!candidateId || !Number.isInteger(version)) { return { kind: 'error', text: 'Usage: /pc review approve ' } } - return call(runtime, scopeId, 'approve_artifact_candidate', { candidate_id: candidateId, expected_version: version }, signal) + return call(runtime, cwd, 'approve_artifact_candidate', { candidate_id: candidateId, expected_version: version }, signal) } if (action === 'reject') { const candidateId = tokens[2] @@ -65,7 +72,7 @@ async function handleReview( if (!candidateId || !Number.isInteger(version) || !reason) { return { kind: 'error', text: 'Usage: /pc review reject ' } } - return call(runtime, scopeId, 'reject_artifact_candidate', { + return call(runtime, cwd, 'reject_artifact_candidate', { candidate_id: candidateId, expected_version: version, reason, }, signal) } @@ -73,44 +80,64 @@ async function handleReview( } async function handleDoctor(runtime: PluginRuntime, signal?: AbortSignal): Promise { - const live = await invokeOperation(runtime.client, 'get_liveness', {}, runtime.config.scopeId ?? '', signal) - const ready = await invokeOperation(runtime.client, 'get_readiness', {}, runtime.config.scopeId ?? '', signal) + const onFailure = (error: unknown) => reportDirectFailure(runtime, 'command', error) + const live = await invokeOperation(runtime.client, 'get_liveness', {}, '', signal, onFailure) + const ready = await invokeOperation(runtime.client, 'get_readiness', {}, '', signal, onFailure) return { kind: live.ok && ready.ok ? 'success' : 'error', text: formatResult({ ok: live.ok && ready.ok, data: { live, ready } }) } } +function statusResult(runtime: PluginRuntime, scopeId?: string, failure?: ToolResult): CommandResult { + let endpoint = '(invalid URL)' + try { + // Display the origin only: credentials, paths, query strings and fragments can contain secrets. + endpoint = new URL(runtime.config.baseUrl).origin + } catch { /* Do not echo an invalid configuration value. */ } + return { + kind: failure ? 'error' : 'success', + text: `scope=${scopeId ?? 'unresolved'}\nbaseUrl=${endpoint}\nUse /pc doctor to check Server readiness.` + + (failure ? `\n${formatResult(failure)}` : ''), + } +} + export async function handlePcCommand( rawInput: string, runtime: PluginRuntime, - scopeId: string, + cwd?: string, signal?: AbortSignal, ): Promise { const tokens = rawInput.trim().split(/\s+/).filter(Boolean) const command = tokens[0] if (!command) { - return { - kind: 'success', - text: `scope=${scopeId}\nbaseUrl=${runtime.config.baseUrl}\nUse /pc doctor to check Server readiness.`, + try { + const scopeId = await runtime.resolveScope(cwd, signal) + return statusResult(runtime, scopeId, + scopeId ? undefined : { ok: false, code: 'unscoped', message: UNSCOPED_MESSAGE }) + } catch (error) { + return statusResult(runtime, undefined, await reportDirectFailure(runtime, 'command', error)) } } if (command === 'doctor') return handleDoctor(runtime, signal) if (command === 'search') { const query = tokens.slice(1).join(' ') if (!query) return { kind: 'error', text: 'Usage: /pc search ' } - return call(runtime, scopeId, 'search_memory', { query, limit: 8, mode: 'auto' }, signal) + return call(runtime, cwd, 'search_memory', { query, limit: 8, mode: 'auto' }, signal) } if (command === 'remember') { const text = tokens.slice(1).join(' ') if (!text) return { kind: 'error', text: 'Usage: /pc remember ' } - return call(runtime, scopeId, 'remember_memory', { kind: 'agent-note', text }, signal) + return call(runtime, cwd, 'remember_memory', { kind: 'agent-note', text }, signal) } - if (command === 'flush') return call(runtime, scopeId, 'flush_memory', {}, signal) - if (command === 'review') return handleReview(tokens, runtime, scopeId, signal) + if (command === 'flush') return call(runtime, cwd, 'flush_memory', {}, signal) + if (command === 'review') return handleReview(tokens, runtime, cwd, signal) if (command === 'skills') { - if (tokens[1] === 'scan') return call(runtime, scopeId, 'scan_external_skills', {}, signal) + if (tokens[1] === 'scan') return call(runtime, cwd, 'scan_external_skills', {}, signal) return { kind: 'error', text: 'Usage: /pc skills scan' } } - if (command === 'stats') return call(runtime, scopeId, 'get_stats', {}, signal) - if (command === 'capabilities') return call(runtime, scopeId, 'get_capabilities', {}, signal) + if (command === 'stats') return call(runtime, cwd, 'get_stats', {}, signal) + if (command === 'capabilities') { + return asResult(await invokeOperation(runtime.client, 'get_capabilities', {}, '', signal, + error => reportDirectFailure(runtime, 'command', error))) + } return { kind: 'error', text: 'Unknown /pc subcommand. Try doctor, search, remember, flush, review, stats, capabilities, skills scan.' } } @@ -128,10 +155,8 @@ export function registerCommands( commands.register({ name: 'pc', description: 'PowerContext status, search, review, and diagnostics', - handler: async (invocation) => { - const scopeId = await runtime.resolveScope(invocation.agent.session.header.cwd) - if (!scopeId) return { kind: 'error', text: UNSCOPED_MESSAGE } - return handlePcCommand(invocation.rawInput, runtime, scopeId, invocation.signal) - }, + handler: async (invocation) => handlePcCommand( + invocation.rawInput, runtime, invocation.agent.session.header.cwd, invocation.signal, + ), }) } diff --git a/integrations/dsh/plugins/powercontext/src/diagnostics.ts b/integrations/dsh/plugins/powercontext/src/diagnostics.ts index 6c9e233fa..bc554684c 100644 --- a/integrations/dsh/plugins/powercontext/src/diagnostics.ts +++ b/integrations/dsh/plugins/powercontext/src/diagnostics.ts @@ -30,8 +30,38 @@ const COMPATIBILITY_OR_AVAILABILITY_PATHS = new Set([ '/health/ready', '/v1/capabilities', '/v1/context/prepare', + '/v1/scope-bindings/resolve', ]) +// Only public protocol codes may cross the diagnostic/model boundary. +const PUBLIC_ERROR_CODES = new Set([ + 'not_found', 'scope_not_found', 'memory_not_found', 'artifact_not_found', + 'candidate_not_found', 'handoff_evidence_not_found', 'source_definition_not_found', + 'external_skill_not_found', 'conflict', 'revision_conflict', 'memory_entry_inactive', + 'source_conflict', 'candidate_conflict', 'artifact_conflict', 'candidate_terminal', + 'scope_version_conflict', 'scope_idempotency_conflict', 'artifact_publication_conflict', + 'connector_checkpoint_conflict', 'generation_conflict', 'external_skill_snapshot_unavailable', + 'handoff_report_inconsistent', 'invalid_request', 'invalid_scope_relationship', + 'invalid_source_ingestion', 'invalid_lifecycle', 'artifact_publication_unsupported', + 'capability_not_supported', 'unauthorized', 'forbidden', 'authentication_failed', + 'runtime_not_ready', 'generation_unavailable', 'inference_timeout', 'inference_unavailable', + 'handoff_generation_unavailable', 'external_skill_registry_unavailable', + 'handoff_report_unavailable', 'handoff_report_too_large', 'invalid_handoff_generation', + 'remote_skill_distribution_error', 'invalid_target_credential', 'invalid_enrollment', + 'invalid_target_state', 'publication_generation_conflict', 'invalid_skill_lifecycle', + 'internal_error', +]) + +export function publicErrorCode(code: unknown): string | undefined { + return typeof code === 'string' && PUBLIC_ERROR_CODES.has(code) ? code : undefined +} + +export function isVersionMismatch(error: ServerResponseError): boolean { + return error.statusCode === 404 + && error.code === undefined + && COMPATIBILITY_OR_AVAILABILITY_PATHS.has(error.path) +} + const AUTOMATIC_OPERATION_PATHS = new Map([ ['context_prepare', '/v1/context/prepare'], ['capture_content_source', '/v1/sources/content'], @@ -39,11 +69,12 @@ const AUTOMATIC_OPERATION_PATHS = new Map([ ]) function responseDiagnostic(event: string, outcome: string, error: ServerResponseError): DiagnosticEvent { + const code = publicErrorCode(error.code) return { event, outcome, http_status: error.statusCode, - ...(error.code ? { error_code: error.code } : {}), + ...(code ? { error_code: code } : {}), } } @@ -54,7 +85,7 @@ function isDomainStatus(status: number): boolean { export function failureEvent(event: string, error: unknown): DiagnosticEvent | undefined { if (error instanceof ServerResponseError) { if (error.statusCode === 401) return responseDiagnostic(event, 'authentication_failed', error) - if (error.statusCode === 404 && COMPATIBILITY_OR_AVAILABILITY_PATHS.has(error.path) && error.code === undefined) { + if (isVersionMismatch(error)) { return responseDiagnostic(event, 'version_mismatch', error) } if (error.statusCode === 503) { diff --git a/integrations/dsh/plugins/powercontext/src/errors.ts b/integrations/dsh/plugins/powercontext/src/errors.ts index 9d8854b10..e1bfc0c1a 100644 --- a/integrations/dsh/plugins/powercontext/src/errors.ts +++ b/integrations/dsh/plugins/powercontext/src/errors.ts @@ -71,17 +71,17 @@ export class SecretRejectedError extends ClientError { export class ServerResponseError extends ClientError { readonly statusCode: number readonly path: string - readonly code: string | undefined + readonly code: unknown readonly serverMessage: string | undefined constructor(options: { statusCode: number path?: string requestId?: string - code?: string + code?: unknown message?: string }) { - const suffix = options.code ? ` (${options.code})` : '' + const suffix = typeof options.code === 'string' ? ` (${options.code})` : '' super(`PowerContext Server returned HTTP ${options.statusCode}${suffix}`, options.requestId) this.statusCode = options.statusCode this.path = options.path ?? '' diff --git a/integrations/dsh/plugins/powercontext/src/index.ts b/integrations/dsh/plugins/powercontext/src/index.ts index 23122cc0d..f77765941 100644 --- a/integrations/dsh/plugins/powercontext/src/index.ts +++ b/integrations/dsh/plugins/powercontext/src/index.ts @@ -67,7 +67,7 @@ function createRuntime(ctx: Context, config: PluginConfig): PluginRuntime { return { client, config: resolved, - resolveScope: (cwd) => resolveScopeId(client, cwd, resolved.scopeId), + resolveScope: (cwd, signal) => resolveScopeId(client, cwd, resolved.scopeId, signal), log: (event) => { const line = JSON.stringify({ component: 'powercontext.dsh', ...event }) const quiet = event.outcome === 'ready' || event.outcome === 'ok' || event.outcome === 'empty' diff --git a/integrations/dsh/plugins/powercontext/src/invoke.ts b/integrations/dsh/plugins/powercontext/src/invoke.ts index 1af7b0806..f65064946 100644 --- a/integrations/dsh/plugins/powercontext/src/invoke.ts +++ b/integrations/dsh/plugins/powercontext/src/invoke.ts @@ -16,7 +16,9 @@ import type { PowerContextClient, JsonObject } from './client.ts' import type { ResolvedConfig } from './config.ts' +import { failureEvent, isVersionMismatch, publicErrorCode } from './diagnostics.ts' import { + InvalidResponseError, SecretRejectedError, ServerResponseError, TransportError, @@ -28,6 +30,7 @@ import { containsSecret } from './secrets.ts' export interface ToolResult { ok: boolean code?: string + error_code?: string message?: string status?: number request_id?: string @@ -47,6 +50,7 @@ export function toolResultSchema(): Record { properties: { ok: { type: 'boolean', required: true }, code: { type: 'string' }, + error_code: { type: 'string' }, message: { type: 'string' }, status: { type: 'number' }, request_id: { type: 'string' }, @@ -60,24 +64,28 @@ export function renderToolResult(_args: unknown, value: ToolResult): Array<{ typ } function mapServerError(error: ServerResponseError): ToolResult { + const code = publicErrorCode(error.code) if (error.statusCode === 401) { return { ok: false, code: 'authentication_failed', message: 'PowerContext authentication failed. Check Authorization.', status: 401, request_id: error.requestId } } if (error.statusCode === 404) { - return { ok: false, code: 'not_found', message: error.serverMessage ?? 'PowerContext resource was not found.', status: 404, request_id: error.requestId } + if (isVersionMismatch(error)) { + return { ok: false, code: 'version_mismatch', message: 'A required PowerContext endpoint is unavailable. Check the Server endpoint and compatible plugin/Server versions.', status: 404, request_id: error.requestId } + } + return { ok: false, code: 'not_found', ...(code ? { error_code: code } : {}), message: code === 'scope_not_found' ? 'PowerContext could not resolve the requested Scope. Check its configuration.' : 'PowerContext resource was not found.', status: 404, request_id: error.requestId } } if (error.statusCode === 409) { - return { ok: false, code: error.code ?? 'conflict', message: error.serverMessage ?? 'citation conflict; refresh and retry once.', status: 409, request_id: error.requestId } + return { ok: false, code: code ?? 'conflict', message: 'PowerContext operation conflicts with the current state. Inspect the current reference before retrying.', status: 409, request_id: error.requestId } } if (error.statusCode === 422) { - return { ok: false, code: error.code ?? 'invalid_request', message: error.serverMessage ?? 'PowerContext rejected the request.', status: 422, request_id: error.requestId } + return { ok: false, code: code ?? 'invalid_request', message: 'PowerContext rejected the request.', status: 422, request_id: error.requestId } } if (error.statusCode === 503) { return { ok: false, code: 'unavailable', message: 'PowerContext is unavailable, continue the task.', status: 503, request_id: error.requestId } } return { ok: false, - code: error.code ?? 'server_error', + code: code ?? 'server_error', message: 'PowerContext is unavailable, continue the task.', status: error.statusCode, request_id: error.requestId, @@ -92,6 +100,9 @@ export function toToolResult(error: unknown): ToolResult { return { ok: false, code: 'unknown_operation', message: error.message } } if (error instanceof ServerResponseError) return mapServerError(error) + if (error instanceof InvalidResponseError) { + return { ok: false, code: 'invalid_response', message: 'PowerContext returned an invalid response.', request_id: error.requestId } + } if (error instanceof TransportError) { return { ok: false, code: 'unavailable', message: 'PowerContext is unavailable, continue the task.' } } @@ -126,6 +137,7 @@ export async function invokeOperation( payload: JsonObject | undefined, scopeId: string, signal?: AbortSignal, + onFailure?: (error: unknown) => unknown, ): Promise { if (!(operationId in OPERATIONS)) return toToolResult(new UnknownOperationError(operationId)) const id = operationId as OperationId @@ -137,15 +149,31 @@ export async function invokeOperation( return toToolResult(new SecretRejectedError()) } try { + if (signal?.aborted) throw new TransportError('', signal.reason) return encodeSuccess(await client.request(id, body, signal)) } catch (error) { + try { + await onFailure?.(error) + } catch { + // Reporting must not turn an operation failure into a host exception. + } return toToolResult(error) } } +export async function reportDirectFailure(runtime: PluginRuntime, event: string, error: unknown): Promise { + try { + const diagnostic = failureEvent(event, error) + if (diagnostic) await runtime.log(diagnostic) + } catch { + // Diagnostics are best effort, including failures before operation dispatch. + } + return toToolResult(error) +} + export interface PluginRuntime { client: PowerContextClient config: ResolvedConfig - resolveScope: (cwd?: string) => Promise + resolveScope: (cwd?: string, signal?: AbortSignal) => Promise log: (event: Record) => void } diff --git a/integrations/dsh/plugins/powercontext/src/scope.ts b/integrations/dsh/plugins/powercontext/src/scope.ts index 4ec455b61..90baffb91 100644 --- a/integrations/dsh/plugins/powercontext/src/scope.ts +++ b/integrations/dsh/plugins/powercontext/src/scope.ts @@ -37,12 +37,13 @@ export async function resolveScopeId( client: PowerContextClient, cwd: string | undefined, configuredScopeId?: string, + signal?: AbortSignal, ): Promise { const workspace = sessionCwd(cwd) const response = await client.request('resolve_scope_binding', { explicit_scope_id: configuredScopeId, binding_keys: workspace ? [workspaceBindingKey(workspace)] : [], - }) + }, signal) const value = response.value const scopeId = value && typeof value === 'object' ? (value as { scope_id?: unknown }).scope_id : undefined return typeof scopeId === 'string' && scopeId.trim() ? scopeId : undefined diff --git a/integrations/dsh/plugins/powercontext/src/tools.ts b/integrations/dsh/plugins/powercontext/src/tools.ts index ba7ed5813..83f445a67 100644 --- a/integrations/dsh/plugins/powercontext/src/tools.ts +++ b/integrations/dsh/plugins/powercontext/src/tools.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { invokeOperation, renderToolResult, toolResultSchema, type PluginRuntime, type ToolResult } from './invoke.ts' +import { invokeOperation, renderToolResult, reportDirectFailure, toolResultSchema, type PluginRuntime, type ToolResult } from './invoke.ts' import type { JsonObject } from './client.ts' import { sessionCwd, UNSCOPED_MESSAGE } from './scope.ts' @@ -55,9 +55,14 @@ async function run( operationId: string, payload: JsonObject, ): Promise { - const scopeId = await runtime.resolveScope(sessionCwd(exec.agent?.session.header.cwd)) - if (!scopeId) return { ok: false, code: 'unscoped', message: UNSCOPED_MESSAGE } - return invokeOperation(runtime.client, operationId, payload, scopeId, exec.signal) + try { + const scopeId = await runtime.resolveScope(sessionCwd(exec.agent?.session.header.cwd), exec.signal) + if (!scopeId) return { ok: false, code: 'unscoped', message: UNSCOPED_MESSAGE } + return await invokeOperation(runtime.client, operationId, payload, scopeId, exec.signal, + error => reportDirectFailure(runtime, 'tool_call', error)) + } catch (error) { + return reportDirectFailure(runtime, 'tool_call', error) + } } type ToolCallKind = 'read' | 'edit' | 'delete' | 'search' diff --git a/integrations/dsh/plugins/powercontext/tests/commands.spec.ts b/integrations/dsh/plugins/powercontext/tests/commands.spec.ts index 557a3b65f..1376cd4be 100644 --- a/integrations/dsh/plugins/powercontext/tests/commands.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/commands.spec.ts @@ -33,13 +33,13 @@ function runtime(fetchImpl: typeof fetch): PluginRuntime { describe('handlePcCommand', () => { it('prints scope on bare /pc', async () => { - const result = await handlePcCommand('', runtime(async () => new Response('{}')), 'project:demo') + const result = await handlePcCommand('', runtime(async () => new Response('{}')), '/workspace') expect(result.kind).toBe('success') expect(result.text).toContain('scope=project:demo') }) it('requires version arguments for review approve', async () => { - const result = await handlePcCommand('review approve only-id', runtime(async () => new Response('{}')), 'project:demo') + const result = await handlePcCommand('review approve only-id', runtime(async () => new Response('{}')), '/workspace') expect(result.kind).toBe('error') expect(result.text).toContain('Usage: /pc review approve') }) diff --git a/integrations/dsh/plugins/powercontext/tests/direct-failures.spec.ts b/integrations/dsh/plugins/powercontext/tests/direct-failures.spec.ts new file mode 100644 index 000000000..b3d86cd94 --- /dev/null +++ b/integrations/dsh/plugins/powercontext/tests/direct-failures.spec.ts @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed 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. + */ + +import { describe, expect, it } from 'vitest' +import { PowerContextClient, type FetchFn } from '../src/client.ts' +import { registerCommands, type CommandResult } from '../src/commands.ts' +import { resolveConfig } from '../src/config.ts' +import { createDiagnosticEmitter } from '../src/diagnostics.ts' +import type { PluginRuntime, ToolResult } from '../src/invoke.ts' +import { resolveScopeId } from '../src/scope.ts' +import { registerTools } from '../src/tools.ts' + +const RESOLVE_PATH = '/v1/scope-bindings/resolve' +const PRIVATE = 'private-response-marker' + +function fixture(fetchImpl: FetchFn, baseUrl = 'http://127.0.0.1:8000', requestTimeoutMs = 1000) { + const calls: Array<{ path: string; body: Record }> = [] + const events: Record[] = [] + const config = resolveConfig({ baseUrl, requestTimeoutMs }, {}) + const client = new PowerContextClient({ + baseUrl: config.baseUrl, + requestTimeoutMs: config.requestTimeoutMs, + fetch: async (url, init) => { + calls.push({ path: new URL(url).pathname, body: JSON.parse(String(init.body ?? '{}')) }) + return fetchImpl(url, init) + }, + }) + const runtime: PluginRuntime = { + client, + config, + resolveScope: (cwd, signal) => resolveScopeId(client, cwd, config.scopeId, signal), + log: createDiagnosticEmitter(line => events.push(JSON.parse(line))), + } + const tools: Array<{ + name: string + execute: (args: Record, exec: unknown) => Promise + }> = [] + registerTools({ tools: { register: tool => tools.push(tool as never) }, on: () => undefined }, runtime, value => value) + let command!: (invocation: { + rawInput: string + signal: AbortSignal + agent: { session: { header: { cwd: string } } } + }) => Promise + registerCommands({ + get: () => ({ register: (definition: { handler: typeof command }) => { command = definition.handler } }), + }, runtime) + const invocation = (signal = new AbortController().signal) => ({ + signal, agent: { session: { header: { cwd: '/workspace' } } }, + }) + return { + calls, events, runtime, + tool: (name: string, args: Record = {}, signal?: AbortSignal) => + tools.find(tool => tool.name === name)!.execute(args, invocation(signal)), + command: (rawInput: string, signal?: AbortSignal) => command({ ...invocation(signal), rawInput }), + } +} + +function domainResponse(status: number, code: unknown): Response { + return Response.json({ error: { code, message: PRIVATE } }, { + status, headers: { 'X-PowerContext-Request-ID': 'request-1' }, + }) +} + +describe.each(['tool', 'command'] as const)('registered %s failure boundary', entry => { + async function remember(h: ReturnType, signal?: AbortSignal): Promise { + if (entry === 'tool') return h.tool('pc_remember', { kind: 'decision', text: 'Keep the API stable.' }, signal) + const result = await h.command('remember Keep the API stable.', signal) + expect(result.kind).toBe('error') + return JSON.parse(result.text) + } + + it.each([ + ['missing route', () => Response.json({ detail: 'Not Found' }, { status: 404 }), 'version_mismatch', 'version_mismatch'], + ['missing Scope', () => domainResponse(404, 'scope_not_found'), 'not_found', undefined], + ['authentication', () => domainResponse(401, 'unauthorized'), 'authentication_failed', 'authentication_failed'], + ['unavailable', () => domainResponse(503, 'runtime_not_ready'), 'unavailable', 'server_unavailable'], + ['invalid response', () => new Response('{broken', { status: 200 }), 'invalid_response', 'invalid_response'], + ] as const)('contains a %s failure before a write', async (_name, response, code, outcome) => { + const h = fixture(async () => response()) + const result = await remember(h) + expect(result).toMatchObject({ ok: false, code }) + if (code === 'not_found') expect(result).toMatchObject({ error_code: 'scope_not_found', request_id: 'request-1' }) + expect(h.calls.map(call => call.path)).toEqual([RESOLVE_PATH]) + expect(h.events.map(event => event.outcome)).toEqual(outcome ? [outcome] : []) + expect(JSON.stringify([result, h.events])).not.toContain(PRIVATE) + expect(JSON.stringify(h.events)).not.toContain('http://') + }) + + it('keeps no resolved Scope distinct from a failed Server', async () => { + const h = fixture(async () => Response.json({})) + h.runtime.resolveScope = async () => undefined + await expect(remember(h)).resolves.toMatchObject({ + ok: false, code: 'unscoped', + }) + expect(h.calls).toEqual([]) + expect(h.events).toEqual([]) + }) + + it('preserves a resource 404 and its domain code after resolving Scope', async () => { + const h = fixture(async url => new URL(url).pathname === RESOLVE_PATH + ? Response.json({ scope_id: 'scope-workspace' }) + : domainResponse(404, 'memory_not_found')) + const result = entry === 'tool' + ? await h.tool('pc_memory_get', { citation: {} }) + : JSON.parse((await h.command('search API')).text) + expect(result).toMatchObject({ + ok: false, code: 'not_found', error_code: 'memory_not_found', status: 404, request_id: 'request-1', + }) + expect(h.events).toEqual([]) + }) + + it.each([ + [409, 'revision_conflict'], + [422, 'invalid_request'], + ])('preserves existing HTTP %s business codes without Server text', async (status, code) => { + const h = fixture(async url => new URL(url).pathname === RESOLVE_PATH + ? Response.json({ scope_id: 'scope-workspace' }) + : domainResponse(status as number, code)) + const result = await remember(h) + expect(result).toMatchObject({ ok: false, code, status }) + expect(JSON.stringify([result, h.events])).not.toContain(PRIVATE) + expect(h.events).toEqual([]) + }) + + it.each([PRIVATE, 123, null, { toString: PRIVATE }])('does not expose an unrecognized 404 code %j or infer a route mismatch', async code => { + const h = fixture(async () => domainResponse(404, code)) + const result = await remember(h) + expect(result).toMatchObject({ ok: false, code: 'not_found' }) + expect(result).not.toHaveProperty('error_code') + expect(JSON.stringify([result, h.events])).not.toContain(PRIVATE) + }) + + it.each(['scope', 'operation'])('contains diagnostic writer errors during %s failure', async stage => { + const h = fixture(async url => { + if (stage === 'operation' && new URL(url).pathname === RESOLVE_PATH) { + return Response.json({ scope_id: 'scope-workspace' }) + } + throw new TypeError(PRIVATE) + }) + h.runtime.log = () => { throw new Error(PRIVATE) } + await expect(remember(h)).resolves.toMatchObject({ + ok: false, code: 'unavailable', + }) + }) + + it('contains a rejected diagnostic callback and omits unrecognized diagnostic codes', async () => { + const h = fixture(async () => domainResponse(401, PRIVATE)) + const result = await remember(h) + expect(result.code).toBe('authentication_failed') + expect(h.events).toEqual([expect.objectContaining({ outcome: 'authentication_failed' })]) + expect(JSON.stringify([result, h.events])).not.toContain(PRIVATE) + h.runtime.log = async () => { throw new Error(PRIVATE) } + await expect(remember(h)).resolves.toMatchObject({ ok: false, code: 'authentication_failed' }) + }) + + it('bounds repeated direct-operation diagnostics', async () => { + const h = fixture(async url => new URL(url).pathname === RESOLVE_PATH + ? Response.json({ scope_id: 'scope-workspace' }) + : domainResponse(503, 'runtime_not_ready')) + await remember(h) + await remember(h) + expect(h.events).toEqual([expect.objectContaining({ + event: entry === 'tool' ? 'tool_call' : 'command', outcome: 'server_unavailable', recovery: 'powercontext doctor', + })]) + }) + + it('cancels Scope resolution before performing a later write', async () => { + let started!: () => void + const entered = new Promise(resolve => { started = resolve }) + const h = fixture(async (_url, init) => new Promise((_resolve, reject) => { + init.signal!.addEventListener('abort', () => reject(init.signal!.reason), { once: true }) + started() + })) + const controller = new AbortController() + const result = remember(h, controller.signal) + await entered + controller.abort() + const bounded = await Promise.race([ + result, + new Promise(resolve => setTimeout(() => resolve('did not cancel'), 200)), + ]) + expect(bounded).toMatchObject({ ok: false, code: 'unavailable' }) + expect(h.calls.map(call => call.path)).toEqual([RESOLVE_PATH]) + expect(h.events.map(event => event.outcome)).toEqual(['server_unavailable']) + }) + + it('bounds a stalled Scope request with the existing per-request timeout', async () => { + const h = fixture(async (_url, init) => new Promise((_resolve, reject) => { + init.signal!.addEventListener('abort', () => reject(init.signal!.reason), { once: true }) + }), undefined, 20) + await expect(remember(h)).resolves.toMatchObject({ ok: false, code: 'unavailable' }) + expect(h.calls.map(call => call.path)).toEqual([RESOLVE_PATH]) + expect(h.events.map(event => event.outcome)).toEqual(['server_unavailable']) + }) + + it('does not dispatch a write when cancellation arrives as Scope resolution completes', async () => { + const controller = new AbortController() + const h = fixture(async () => Response.json({})) + h.runtime.resolveScope = async () => { + controller.abort() + return 'scope-workspace' + } + await expect(remember(h, controller.signal)).resolves.toMatchObject({ ok: false, code: 'unavailable' }) + expect(h.calls).toEqual([]) + expect(h.events.map(event => event.outcome)).toEqual(['server_unavailable']) + }) +}) + +describe('registered /pc command routing', () => { + it('checks health and capabilities without a working Scope endpoint', async () => { + const h = fixture(async url => new URL(url).pathname === RESOLVE_PATH + ? Response.json({ detail: 'Not Found' }, { status: 404 }) + : Response.json({ status: 'ready' })) + expect((await h.command('doctor')).kind).toBe('success') + expect((await h.command('capabilities')).kind).toBe('success') + expect(h.calls.map(call => call.path)).toEqual(['/health/live', '/health/ready', '/v1/capabilities']) + }) + + it('keeps both Doctor results when one health endpoint fails', async () => { + const h = fixture(async url => new URL(url).pathname === '/health/ready' + ? domainResponse(503, 'runtime_not_ready') + : Response.json({ status: 'alive' })) + const result = await h.command('doctor') + expect(result.kind).toBe('error') + expect(JSON.parse(result.text).data).toMatchObject({ + live: { ok: true }, ready: { ok: false, code: 'unavailable' }, + }) + }) + + it.each(['unknown', 'search', 'remember', 'review approve only-id', 'review reject id 1', 'skills'])( + 'validates "%s" without resolving Scope', async rawInput => { + const h = fixture(async () => { throw new TypeError('Server unavailable') }) + const result = await h.command(rawInput) + expect(result.kind).toBe('error') + expect(result.text).toMatch(/Usage:|Unknown/) + expect(h.calls).toEqual([]) + }, + ) + + it('keeps bare status available and redacts endpoint secrets when Scope fails', async () => { + const h = fixture( + async () => domainResponse(404, 'scope_not_found'), + 'http://user:private-response-marker@example.test/prefix?token=private-response-marker#private-response-marker', + ) + h.runtime.config.scopeId = 'configured-but-unresolved' + const result = await h.command('') + expect(result.kind).toBe('error') + expect(result.text).toContain('scope=unresolved') + expect(result.text).toContain('scope_not_found') + expect(result.text).toContain('/pc doctor') + expect(result.text).not.toContain(PRIVATE) + expect(result.text).not.toContain('configured-but-unresolved') + expect(h.calls.map(call => call.path)).toEqual(['/prefix']) + }) + + it('contains a scoped command failure without writing or selecting another Scope', async () => { + const h = fixture(async () => domainResponse(404, 'scope_not_found')) + const result = await h.command('remember Keep the API stable.') + expect(result.kind).toBe('error') + expect(JSON.parse(result.text)).toMatchObject({ + ok: false, code: 'not_found', error_code: 'scope_not_found', + }) + expect(h.calls.map(call => call.path)).toEqual([RESOLVE_PATH]) + }) + + it('keeps stats restricted to the resolved Scope', async () => { + const h = fixture(async url => new URL(url).pathname === RESOLVE_PATH + ? Response.json({ scope_id: 'scope-workspace' }) + : Response.json({})) + expect((await h.command('stats')).kind).toBe('success') + expect(h.calls.find(call => call.path === '/v1/stats')?.body).toMatchObject({ + selection: { mode: 'exact', scope_ids: ['scope-workspace'] }, + }) + }) +}) diff --git a/integrations/dsh/plugins/powercontext/tests/e2e/unscoped-session.spec.ts b/integrations/dsh/plugins/powercontext/tests/e2e/unscoped-session.spec.ts index 86fb30a41..15167d1e5 100644 --- a/integrations/dsh/plugins/powercontext/tests/e2e/unscoped-session.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/e2e/unscoped-session.spec.ts @@ -90,7 +90,7 @@ function createPluginRuntime( runtime: { client, config, - resolveScope: (cwd) => resolveScopeId(client, cwd, config.scopeId), + resolveScope: (cwd, signal) => resolveScopeId(client, cwd, config.scopeId, signal), log: (event) => { events.push(event) }, @@ -219,4 +219,29 @@ describe('plugin runtime with header.cwd === undefined', () => { expect((capture?.body?.metadata as { cwd?: string } | undefined)?.cwd).toBeUndefined() expect(calls.every((call) => !String(call.body?.scope_id ?? '').startsWith('local:'))).toBe(true) }) + + it('keeps diagnostics usable when an explicit Scope does not exist', async () => { + const { fetchImpl, calls } = trackingFetch() + const { runtime } = createPluginRuntime(server.baseUrl, 'scp_00000000000000000000000000', fetchImpl) + const command = pcHandler(runtime) + const invocation = () => ({ signal: AbortSignal.timeout(5000), agent: sessionWithoutCwd() }) + + const remembered = await toolNamed(runtime, 'pc_remember').execute({ kind: 'agent-note', text: TEXT }, invocation()) + expect(remembered).toMatchObject({ ok: false, code: 'not_found', error_code: 'scope_not_found', status: 404 }) + const searched = await command({ ...invocation(), rawInput: 'search optional cwd' }) + expect(searched.kind).toBe('error') + expect(JSON.parse(searched.text)).toMatchObject({ code: 'not_found', error_code: 'scope_not_found' }) + + const status = await command({ ...invocation(), rawInput: '' }) + expect(status.kind).toBe('error') + expect(status.text).toContain('scope=unresolved') + const doctor = await command({ ...invocation(), rawInput: 'doctor' }) + expect(doctor.kind).toBe('success') + expect(JSON.parse(doctor.text).data).toMatchObject({ live: { ok: true }, ready: { ok: true } }) + expect((await command({ ...invocation(), rawInput: 'capabilities' })).kind).toBe('success') + expect(calls.map(call => call.path)).toEqual([ + '/v1/scope-bindings/resolve', '/v1/scope-bindings/resolve', '/v1/scope-bindings/resolve', + '/health/live', '/health/ready', '/v1/capabilities', + ]) + }) }) diff --git a/integrations/dsh/plugins/powercontext/tests/scope.spec.ts b/integrations/dsh/plugins/powercontext/tests/scope.spec.ts index c1ca1501d..ae41e633d 100644 --- a/integrations/dsh/plugins/powercontext/tests/scope.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/scope.spec.ts @@ -36,9 +36,9 @@ describe('Scope binding', () => { it('uses the Server default when cwd is absent', async () => { const request = vi.fn().mockResolvedValue({ value: { scope_id: 'default-scope' } }) await expect(resolveScopeId({ request } as never, undefined)).resolves.toBe('default-scope') - expect(request).toHaveBeenCalledWith('resolve_scope_binding', { + expect(request.mock.calls[0].slice(0, 2)).toEqual(['resolve_scope_binding', { explicit_scope_id: undefined, binding_keys: [], - }) + }]) }) })