diff --git a/docs-site/src/content/docs/fr/reference/configuration/advisor.md b/docs-site/src/content/docs/fr/reference/configuration/advisor.md index de932d06d6b..391b556d058 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/fr/reference/configuration/advisor.md @@ -43,7 +43,7 @@ Sur la page **Advisor**, choisissez le modèle dans le catalogue. Le sélecteur Gérez-le via la page **Advisor** du tableau de bord ou `ocx advisor status|on|off|set --model --effort --policy `. -Sans consentement courant, `ocx advisor on` n'active pas le partage inter-fournisseurs : il affiche cette divulgation et s'arrête. `ocx advisor on --ack-context-sharing` et `ocx advisor consent` enregistrent `v1`. `ocx advisor consent --revoke` retire le consentement et arrête immédiatement l'envoi. `ocx advisor set` n'accorde pas le consentement. La case du tableau de bord n'est pas précochée. +Sans consentement courant, `ocx advisor on` n'active pas le partage inter-fournisseurs : il affiche cette divulgation et s'arrête. `ocx advisor on --ack-context-sharing` et `ocx advisor consent` enregistrent `v1`. `ocx advisor consent --revoke` retire le consentement. Les consultations qui n'ont pas encore été envoyées sont bloquées, mais une consultation déjà envoyée ne peut pas être annulée, et le contexte déjà transmis hors du proxy ne peut pas être retiré. `ocx advisor set` n'accorde pas le consentement. La case du tableau de bord n'est pas précochée. ## Politiques diff --git a/docs-site/src/content/docs/ja/reference/configuration/advisor.md b/docs-site/src/content/docs/ja/reference/configuration/advisor.md index 59b4614e1ed..304ccdd9723 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/ja/reference/configuration/advisor.md @@ -35,7 +35,7 @@ description: manual、preflight、証拠に基づく adaptive ポリシーによ ダッシュボードの **Advisor** ページまたは `ocx advisor status|on|off|set --model --effort --policy ` で管理します。 -現在の同意がないとき、`ocx advisor on` はプロバイダー間送信を有効にしません。開示を表示して停止します。`ocx advisor on --ack-context-sharing` と `ocx advisor consent` が `v1` を記録します。`ocx advisor consent --revoke` は同意を消し、送信を直ちに止めます。`ocx advisor set` は同意を与えません。ダッシュボードの同意チェックは初期状態でオフです。 +現在の同意がないとき、`ocx advisor on` はプロバイダー間送信を有効にしません。開示を表示して停止します。`ocx advisor on --ack-context-sharing` と `ocx advisor consent` が `v1` を記録します。`ocx advisor consent --revoke` は同意を消します。まだディスパッチされていない相談はブロックされますが、すでにディスパッチされた相談は取り消せず、すでにプロキシを離れた文脈も取り戻せません。`ocx advisor set` は同意を与えません。ダッシュボードの同意チェックは初期状態でオフです。 ## ポリシー diff --git a/docs-site/src/content/docs/ko/reference/configuration/advisor.md b/docs-site/src/content/docs/ko/reference/configuration/advisor.md index dd7c22c8114..22c2dad1369 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/ko/reference/configuration/advisor.md @@ -35,7 +35,7 @@ description: manual, preflight 및 증거 기반 adaptive 정책을 제공하는 대시보드 **Advisor** 페이지 또는 `ocx advisor status|on|off|set --model --effort --policy `로 관리합니다. -현재 동의가 없으면 `ocx advisor on`은 프로바이더 간 전송을 켜지 않습니다. 고지를 출력하고 멈춥니다. `ocx advisor on --ack-context-sharing`와 `ocx advisor consent`가 `v1`을 기록합니다. `ocx advisor consent --revoke`는 동의를 지우고 전송을 즉시 멈춥니다. `ocx advisor set`은 동의를 부여하지 않습니다. 대시보드의 동의 확인란은 미리 선택되지 않습니다. +현재 동의가 없으면 `ocx advisor on`은 프로바이더 간 전송을 켜지 않습니다. 고지를 출력하고 멈춥니다. `ocx advisor on --ack-context-sharing`와 `ocx advisor consent`가 `v1`을 기록합니다. `ocx advisor consent --revoke`는 동의를 지웁니다. 아직 디스패치되지 않은 상담은 차단되지만, 이미 디스패치된 상담은 취소할 수 없고 이미 프록시를 떠난 맥락도 되돌릴 수 없습니다. `ocx advisor set`은 동의를 부여하지 않습니다. 대시보드의 동의 확인란은 미리 선택되지 않습니다. ## 정책 diff --git a/docs-site/src/content/docs/reference/configuration/advisor.md b/docs-site/src/content/docs/reference/configuration/advisor.md index 36c557abf5d..e27dd07d19a 100644 --- a/docs-site/src/content/docs/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/reference/configuration/advisor.md @@ -206,6 +206,12 @@ consultation never switches the session's main model. - Native OpenAI passthrough turns (ChatGPT-pool workers) do not get the synthetic tool; advisor support covers routed (translated) providers. Preflight consultation applies to run-turn adapters; the tool does not. +- Translated `/v1/messages` requests (for example Claude Code routed to a translated provider) + get the synthetic tool and preflight like `/v1/responses` and `/v1/chat/completions`. The + same `policy` and context-sharing consent rules apply, and the settings are read from the live + config, so a consent revocation blocks consultations for requests already in flight that have + not yet been dispatched. It does not cancel a dispatched consultation or retract context that + already left the proxy. - No semantic stuck detection, completion review, escalation tiers, multiple advisors or voting. Adaptive consultation requires the structured validation/edit/retry evidence described above. - The preflight dedup ledger is process-local; after a proxy restart, a task in progress may diff --git a/docs-site/src/content/docs/ru/reference/configuration/advisor.md b/docs-site/src/content/docs/ru/reference/configuration/advisor.md index 30b9a518175..2cd2076ca11 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/ru/reference/configuration/advisor.md @@ -35,7 +35,7 @@ description: Независимые консультации с политика Управляйте через страницу **Advisor** на дашборде или `ocx advisor status|on|off|set --model --effort --policy `. -Без текущего согласия `ocx advisor on` не включает межпровайдерную отправку: команда печатает раскрытие и останавливается. `ocx advisor on --ack-context-sharing` и `ocx advisor consent` записывают `v1`. `ocx advisor consent --revoke` снимает согласие и сразу прекращает отправку. `ocx advisor set` согласие не выдаёт. Флажок на дашборде изначально снят. +Без текущего согласия `ocx advisor on` не включает межпровайдерную отправку: команда печатает раскрытие и останавливается. `ocx advisor on --ack-context-sharing` и `ocx advisor consent` записывают `v1`. `ocx advisor consent --revoke` снимает согласие. Ещё не отправленные консультации блокируются, но уже отправленную консультацию нельзя отменить, а ушедший из прокси контекст нельзя отозвать. `ocx advisor set` согласие не выдаёт. Флажок на дашборде изначально снят. ## Политики diff --git a/docs-site/src/content/docs/tr/reference/configuration/advisor.md b/docs-site/src/content/docs/tr/reference/configuration/advisor.md index b371679afc1..519d2717671 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/tr/reference/configuration/advisor.md @@ -35,7 +35,7 @@ Kontrol panelindeki **Advisor** sayfasında katalogdan model seçin. Yerel, sağ Panodaki **Advisor** sayfası veya `ocx advisor status|on|off|set --model --effort --policy ` ile yönetin. -Güncel onay yokken `ocx advisor on` sağlayıcılar arası gönderimi açmaz: açıklamayı basar ve durur. `ocx advisor on --ack-context-sharing` ile `ocx advisor consent` `v1` kaydeder. `ocx advisor consent --revoke` onayı kaldırır ve gönderimi hemen durdurur. `ocx advisor set` onay vermez. Panodaki onay kutusu önceden işaretli değildir. +Güncel onay yokken `ocx advisor on` sağlayıcılar arası gönderimi açmaz: açıklamayı basar ve durur. `ocx advisor on --ack-context-sharing` ile `ocx advisor consent` `v1` kaydeder. `ocx advisor consent --revoke` onayı kaldırır. Henüz gönderilmemiş danışmalar engellenir, ancak gönderilmiş bir danışma iptal edilemez ve proxy'den ayrılmış bağlam geri alınamaz. `ocx advisor set` onay vermez. Panodaki onay kutusu önceden işaretli değildir. ## Politikalar diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/advisor.md b/docs-site/src/content/docs/zh-cn/reference/configuration/advisor.md index da488791a18..d0b3f324abe 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/advisor.md @@ -35,7 +35,7 @@ description: 独立专家咨询,支持 manual、preflight 与基于证据的 a 通过仪表盘的 **Advisor** 页面或 `ocx advisor status|on|off|set --model --effort --policy ` 管理。 -`ocx advisor on` 在没有当前同意时不会开启跨 provider 发送:它会打印披露并停止。`ocx advisor on --ack-context-sharing` 与 `ocx advisor consent` 记录 `v1`。`ocx advisor consent --revoke` 会移除同意并立即停止发送。`ocx advisor set` 不授予同意。仪表盘上的同意复选框默认不勾选。 +`ocx advisor on` 在没有当前同意时不会开启跨 provider 发送:它会打印披露并停止。`ocx advisor on --ack-context-sharing` 与 `ocx advisor consent` 记录 `v1`。`ocx advisor consent --revoke` 会移除同意。尚未发出的咨询会被阻断,但已经发出的咨询无法取消,已经离开代理的上下文也无法收回。`ocx advisor set` 不授予同意。仪表盘上的同意复选框默认不勾选。 ## 策略 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/advisor.md b/docs-site/src/content/docs/zh-tw/reference/configuration/advisor.md index 917cb4b8bc6..68d4d849814 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/advisor.md @@ -35,7 +35,7 @@ description: 獨立專家諮詢,支援 manual、preflight 與依據證據的 a 透過儀表板的 **Advisor** 頁面或 `ocx advisor status|on|off|set --model --effort --policy ` 管理。 -沒有目前同意時,`ocx advisor on` 不會開啟跨 provider 傳送:它會印出揭露並停止。`ocx advisor on --ack-context-sharing` 與 `ocx advisor consent` 記錄 `v1`。`ocx advisor consent --revoke` 會移除同意並立即停止傳送。`ocx advisor set` 不授予同意。儀表板上的同意核取方塊預設不勾選。 +沒有目前同意時,`ocx advisor on` 不會開啟跨 provider 傳送:它會印出揭露並停止。`ocx advisor on --ack-context-sharing` 與 `ocx advisor consent` 記錄 `v1`。`ocx advisor consent --revoke` 會移除同意。尚未發出的諮詢會被阻擋,但已經發出的諮詢無法取消,已經離開代理的上下文也無法收回。`ocx advisor set` 不授予同意。儀表板上的同意核取方塊預設不勾選。 ## 策略 diff --git a/scripts/test-layout/layout-agent-assertion.json b/scripts/test-layout/layout-agent-assertion.json index 190baeb209e..0fd249faf55 100644 --- a/scripts/test-layout/layout-agent-assertion.json +++ b/scripts/test-layout/layout-agent-assertion.json @@ -10,6 +10,7 @@ "advisor-caller-policy-routing.test.ts": "advisor", "advisor-caller-policy-host-key.test.ts": "advisor", "advisor-caller-policy-self-fetch.test.ts": "advisor", + "advisor-messages-replay-config.test.ts": "advisor", "windows-native-principal.test.ts": "windows", "fixture-windows-runtime-preload.test.ts": "ci-workflows", "agent-assertion-auth.test.ts": "server", diff --git a/src/advisor/runtime.ts b/src/advisor/runtime.ts index 82c3e6d1287..6af97c74126 100644 --- a/src/advisor/runtime.ts +++ b/src/advisor/runtime.ts @@ -36,6 +36,7 @@ import { } from "./state"; import { formatAdvisorAdvice, advisorPreflightMessages, formatAdvisorUnavailable } from "./context"; import { advisorActivityStore, type AdvisorActivityStore, type AdvisorTrigger } from "./activity"; +import { advisorPlanRootConfig } from "../server/responses/advisor-plan-slot"; import { createAdvisorTaskBudget, type AdvisorTaskBudget } from "./budget"; import { collectAdvisorRepairEvidence, type AdvisorRepairEvidence } from "./evidence"; import type { AdmissionModelScope } from "../server/admission-model-scope"; @@ -95,7 +96,10 @@ export function createAdvisorRuntimePlan(deps: AdvisorRuntimeDeps): AdvisorRunti const ledger = deps.ledger ?? sharedPreflightLedger; const now = deps.now ?? (() => Date.now()); const budget = deps.budget ?? createAdvisorTaskBudget(); - const activities = deps.activities ?? advisorActivityStore(deps.config); + // Identity-scoped state must key on the LIVE config: `GET /api/advisor/activity` reads the store + // of the management context's own config, so a request-scoped view (the Claude replay config) + // would record consultations the dashboard and CLI never see. + const activities = deps.activities ?? advisorActivityStore(advisorPlanRootConfig(deps.config)); // Management PUT mutates this same config object (`config.advisor = ...`). Re-resolving // at dispatch is what makes a mid-request consent revocation stop outbound transfer. const liveSettings = () => resolveAdvisorSettings(deps.config); diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index da26632962e..3a0fc6728d2 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -46,6 +46,7 @@ import { captureAdmissionPolicy } from "./capture-admission-policy"; import type { CapturedAdmissionPolicy } from "../types/admission-policy"; import { captureClaudeInbound } from "../claude/inbound-debug"; import { declaredAnthropicServerTools, replayedAnthropicServerBlocks, systemCachePrefix } from "../claude/inbound-content-options"; +import { linkAdvisorPlanConfig } from "./responses/advisor-plan-slot"; import { claudeCodeForIngress } from "../claude/intercept/model-bindings"; import { classifyInterceptClient } from "../claude/intercept/client-class"; import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility"; @@ -1265,12 +1266,13 @@ async function handleClaudeMessagesWithBudget( // BEFORE translation (the translated Anthropic stream has no response.completed // frame, so tapping it records a bogus 502 with no usage/cache detail). const finalizeNativeLog = createFinalRequestLog(logIds, logCtx).finish; - const upstream = await handleResponses(internalReq, { + // The replay view is a new object; link it so the Advisor factory registered on `config` applies. + const upstream = await handleResponses(internalReq, linkAdvisorPlanConfig(config, { ...buildClaudeReplayConfig(config), // Operator config wins; otherwise the caller's explicit TTLs (all 1h, or a pinned 5m) carry // over, and TTL-less breakpoints take the destination default. cacheRetention: config.cacheRetention ?? callerCacheRetention(anthropicBody), - }, logCtx, { + }), logCtx, { ...(nativeComboSource && envelope && settledRoute?.combo ? { protocolSource: (await import("./messages-native")).createNativeMessagesComboSource({ req, config, logCtx, envelope, requestedModel, translatorBudget, selector: nativeSelector, diff --git a/src/server/responses/advisor-plan-slot.ts b/src/server/responses/advisor-plan-slot.ts index 55174d00c6e..eea097b8ebf 100644 --- a/src/server/responses/advisor-plan-slot.ts +++ b/src/server/responses/advisor-plan-slot.ts @@ -29,6 +29,38 @@ export function setAdvisorPlanFactory(config: OcxConfig, factory: AdvisorPlanFac else factories.delete(config); } +/** Request-scoped config views (e.g. the Claude replay config) -> the live config they were built from. */ +type AdvisorLinkedConfig = Pick; +const views = new WeakMap(); + +/** + * Record that `view` is a request-scoped copy of `live`. A view resolves the live config's + * factory at lookup time, so a later disable or re-activation of the live config still applies. + * The view's `advisor` reads through to the live config: management writes replace that object + * (`config.advisor = ...`), so a copied reference would keep a revoked consent alive for the + * plan. Every other request-scoped override on the view is kept. Nothing is stored on the live + * config, and an accessor is still cloneable (#7974). + */ +export function linkAdvisorPlanConfig(live: OcxConfig, view: T): T { + const root = views.get(live) ?? live; + views.set(view, root); + if (view !== root) { + Object.defineProperty(view, "advisor", { get: () => root.advisor, enumerable: true, configurable: true }); + } + return view; +} + +/** + * The live config a request-scoped view was linked to, or the config itself when it was not + * linked. Identity-scoped state that must outlive one request (the activity feed the management + * route reads from its own config object) resolves through the same link as the factory. + */ +export function advisorPlanRootConfig(config: T): T | OcxConfig { + return views.get(config) ?? config; +} + +/** Create the plan from the factory registered for this config, or for the live config it was linked to. */ export function createRegisteredAdvisorPlan(input: AdvisorPlanInput): Promise | null { - return factories.get(input.config)?.(input) ?? null; + const live = views.get(input.config); + return (factories.get(input.config) ?? (live && factories.get(live)))?.(input) ?? null; } diff --git a/structure/advisor.md b/structure/advisor.md index dbaf0bfdf56..97716650227 100644 --- a/structure/advisor.md +++ b/structure/advisor.md @@ -19,6 +19,13 @@ The server composition root registers it synchronously at startup; a successful updates activation, including enable after startup and disable/reset. The runtime is loaded only when an active factory is used, so its process-local ledger is not constructed by an inactive install. `sidecar-execution.ts` reads the core-owned slot, not Advisor settings or runtime. +Request-scoped copies of the live config, such as the Claude replay config `/v1/messages` passes to +the Responses pipeline, are linked with `linkAdvisorPlanConfig` so they resolve the live config's +factory at lookup time. The view's `advisor` reads through to the live config, so a consent +revocation after the copy still blocks outbound transfer up to dispatch: the runtime rechecks +consent immediately before the loopback call, and a revocation after that call has been dispatched +neither cancels it nor retracts context already sent +(`tests/advisor/advisor-messages-replay-config.test.ts`). Advisor settings admit the complete synchronous candidate/read/patch/save/activation phase through the [config admission contract](config.md). Production uses the current disk candidate when present, preserving unrelated disk edits during admission. Successful saves adopt the committed Advisor subtree and its field-scoped live baseline together, including known absence after reset, without advancing unrelated pending live edits. Fallback or error-bearing diagnostics are refused before mutation. Deadline refusal leaves live and durable settings untouched and retains the existing `write_failed` response. Successful receipts are serialized in that callback before another settings mutation can run. Management loads `advisor-routes.ts` only for the Advisor namespace. The guard is attached through `_advisorGuard`. `tests/advisor/advisor-core-boundary.test.ts` enforces the transitive load-time @@ -332,7 +339,9 @@ request under the advisor model. Consultation usage is never merged into the wor usage; intercepted worker legs are, via the same usage-merge rule the terminal guard applies. `src/advisor/activity.ts` holds config-scoped process memory: at most 100 records, at most 24 -hours old, with pruning on access and no timer. `GET /api/advisor/activity` and `ocx advisor +hours old, with pruning on access and no timer. The store is keyed by the LIVE config object, so a +request that runs on a linked view (the Claude replay config) records into the feed the management +route reads. `GET /api/advisor/activity` and `ocx advisor activity` expose trigger, status, models, requested/mapped effort, duration and reported tokens. There are no task bodies, commands, advice, conversation identifiers or provider error bodies in these records. Dashboard polling runs only while its page is visible. Restart clears activity, diff --git a/tests/advisor/advisor-messages-replay-config.test.ts b/tests/advisor/advisor-messages-replay-config.test.ts new file mode 100644 index 00000000000..95a4e5e09b3 --- /dev/null +++ b/tests/advisor/advisor-messages-replay-config.test.ts @@ -0,0 +1,282 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createAdvisorRuntimePlan, type AdvisorRuntimeDeps } from "../../src/advisor/runtime"; +import { createAdvisorTaskBudget } from "../../src/advisor/budget"; +import { createAdvisorActivityStore } from "../../src/advisor/activity"; +import { createAdvisorPreflightLedger } from "../../src/advisor/state"; +import { parseRequest } from "../../src/responses/parser"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { handleAdvisorRoutes } from "../../src/server/management/advisor-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import { + createRegisteredAdvisorPlan, linkAdvisorPlanConfig, setAdvisorPlanFactory, type AdvisorPlanInput, +} from "../../src/server/responses/advisor-plan-slot"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import type { OcxConfig, OcxParsedRequest } from "../../src/types"; + +// The Advisor factory is registered on the live config object, but /v1/messages hands the +// Responses pipeline a request-scoped replay view (`buildClaudeReplayConfig` plus cacheRetention). +// Looking the factory up by that view alone found nothing, so Claude Code never saw the advisor +// tool and preflight never ran while /v1/chat/completions on the same config worked. + +const workerStream = 'data: {"choices":[{"delta":{"content":"fixture"},"finish_reason":null}]}\n\n' + + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n'; + +/** Distinctive advice so the dispatched worker body proves what the preflight pass delivered. */ +const ADVICE = "advise from the translated messages fixture"; + +/** Distinctive advice so the worker body proves it carried the preflight payload. */ +const STUB_ADVICE = '{"advisor_result":{"status":"advice","advice":"stub-advice-marker"}}'; + +const originalFetch = globalThis.fetch; +const roots: string[] = []; +afterEach(() => { + globalThis.fetch = originalFetch; + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +const plan = (seen: AdvisorPlanInput[], preflights: OcxParsedRequest[] = []) => async (input: AdvisorPlanInput) => { + seen.push(input); + return { + tool: { name: "advisor", description: "fixture", parameters: { type: "object", properties: {} }, advisor: true }, + preflightInject: async (parsed: OcxParsedRequest) => { + preflights.push(parsed); + parsed.context.messages = [ + ...parsed.context.messages, + { role: "user", content: STUB_ADVICE, timestamp: Date.now() }, + ]; + return true; + }, + attachGuard: () => {}, + }; +}; + +test("a linked request view resolves the live factory at lookup time", async () => { + const live = { port: 10100, providers: {} } as OcxConfig; + const view = linkAdvisorPlanConfig(live, { ...live, cacheRetention: "short" as const }); + const nested = linkAdvisorPlanConfig(view, { ...view }); + const seen: AdvisorPlanInput[] = []; + expect(createRegisteredAdvisorPlan({ config: view, workerIdentity: "w", workerModelId: "w" })).toBeNull(); + setAdvisorPlanFactory(live, plan(seen)); + try { + expect((await createRegisteredAdvisorPlan({ config: view, workerIdentity: "w", workerModelId: "w" }))?.tool.name).toBe("advisor"); + expect((await createRegisteredAdvisorPlan({ config: nested, workerIdentity: "w", workerModelId: "w" }))?.tool.name).toBe("advisor"); + // The plan still receives the view it was asked for, not the live object. + expect(seen.map(input => input.config)).toEqual([view, nested]); + expect(seen[0]!.config).toBe(view); + // The read-through accessor keeps the view cloneable (#7974). + expect(structuredClone(view).cacheRetention).toBe("short"); + // An unrelated copy never inherits a factory it was not linked to. + expect(createRegisteredAdvisorPlan({ config: { ...live }, workerIdentity: "w", workerModelId: "w" })).toBeNull(); + } finally { setAdvisorPlanFactory(live, null); } + // Disabling the live config detaches every view linked to it. + expect(createRegisteredAdvisorPlan({ config: view, workerIdentity: "w", workerModelId: "w" })).toBeNull(); +}); + +test("/v1/messages reaches the factory registered on the live config and sends the advisor tool", async () => { + const release = acquireOwnedSpendHome(); + const workerBodies: Record[] = []; + const seen: AdvisorPlanInput[] = []; + const preflights: OcxParsedRequest[] = []; + const config: OcxConfig = { + defaultProvider: "worker", + providers: { worker: { adapter: "openai-chat", apiKey: "fixture", models: ["m"], liveModels: false, + baseUrl: "https://worker.test/v1", + fetch: (async (_url, init) => { + workerBodies.push(JSON.parse(String(init?.body))); + return new Response(workerStream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch } }, + }; + setAdvisorPlanFactory(config, plan(seen, preflights)); + try { + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "worker/m", max_tokens: 100, stream: false, messages: [{ role: "user", content: "fixture" }] }), + }), config, { model: "", provider: "" }, { requestId: "advisor-messages", start: Date.now() }); + await response.text(); + expect(response.status).toBe(200); + expect(seen).toHaveLength(1); + expect(seen[0]!.config).not.toBe(config); + // The Messages turn runs the automatic preflight pass, not only the tool declaration. + expect(preflights).toHaveLength(1); + expect(JSON.stringify(preflights[0]!.context.messages)).toContain("fixture"); + expect(workerBodies).toHaveLength(1); + const tools = (workerBodies[0]!.tools ?? []) as { function?: { name?: string } }[]; + expect(tools.map(tool => tool.function?.name)).toContain("advisor"); + // The advice the preflight pass injected reached the dispatch, not just the tool declaration. + expect(JSON.stringify(workerBodies[0])).toContain("stub-advice-marker"); + } finally { setAdvisorPlanFactory(config, null); release(); } +}); + +test("consent revoked on the live config after the view was copied blocks the consultation", async () => { + const root = mkdtempSync(join(tmpdir(), "advisor-messages-consent-")); roots.push(root); + const calls: unknown[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + calls.push(init?.body); + return Response.json({ choices: [{ message: { content: "advice" } }] }); + }) as typeof fetch; + const live = { port: 10100, providers: {}, advisor: { + enabled: true, model: "expert", policy: "manual", effort: "high", contextSharingConsent: "v1", + } } as OcxConfig; + const view = linkAdvisorPlanConfig(live, { ...live, cacheRetention: "short" as const }); + setAdvisorPlanFactory(live, async input => createAdvisorRuntimePlan({ + ...input, baseUrlOverride: "http://expert.test", ledger: createAdvisorPreflightLedger(), + budget: createAdvisorTaskBudget(join(root, "budget.sqlite")), activities: createAdvisorActivityStore(), + })); + try { + const registered = await createRegisteredAdvisorPlan({ config: view, workerIdentity: "w", workerModelId: "w" }); + expect(registered?.consult).toBeDefined(); + // Management writes replace the live object (`config.advisor = ...`) rather than mutating it. + const { contextSharingConsent: _revoked, ...rest } = live.advisor!; + live.advisor = rest; + const parsed = parseRequest({ model: "w", stream: false, input: [{ role: "user", content: "private-task-text" }] }); + const result = await registered!.consult!(parsed, "manual", "question"); + expect(result.blocked).toBe("consent"); + expect(calls).toHaveLength(0); + // Request-scoped overrides on the view are untouched. + expect(view.cacheRetention).toBe("short"); + } finally { setAdvisorPlanFactory(live, null); } +}); + +/** The management activity route as the dashboard and CLI reach it: it reads its own config object. */ +async function managementActivity(config: OcxConfig) { + const ctx: ManagementContext = { + req: new Request("http://localhost/api/advisor/activity"), + url: new URL("http://localhost/api/advisor/activity"), + config, + deps: {}, + version: "test", + trustedLoopbackIngress: false, + guiSessionIssuance: null, + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {}, + }; + const response = await handleAdvisorRoutes(ctx); + return (await response!.json()) as { entries: { trigger: string; status: string; advisorModel: string }[] }; +} + +/** A real translated /v1/messages turn: worker provider "worker", Advisor routed to "expert". */ +function translatedAdvisorFixture(options: { revokeOnDispatch?: boolean } = {}) { + const root = mkdtempSync(join(tmpdir(), "advisor-messages-fixture-")); roots.push(root); + const workerBodies: Record[] = []; + const expertBodies: { url: string; body: Record }[] = []; + let revoked = false; + const config: OcxConfig = { + defaultProvider: "worker", + advisor: { enabled: true, model: "expert/worker", policy: "preflight", effort: "high", contextSharingConsent: "v1" }, + providers: { + worker: { adapter: "openai-chat", apiKey: "fixture", models: ["m"], liveModels: false, + baseUrl: "https://worker.test/v1", + fetch: (async (_url, init) => { + workerBodies.push(JSON.parse(String(init?.body))); + return new Response(workerStream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch }, + expert: { adapter: "openai-chat", apiKey: "fixture", models: ["worker"], liveModels: false, + baseUrl: "https://expert.test/v1", + fetch: (async (url, init) => { + // Provider-facing model id is the target model ("worker"); the destination proves the route. + expertBodies.push({ url: String(url), body: JSON.parse(String(init?.body)) }); + // A revocation that lands only after the outbound hop has begun. + if (options.revokeOnDispatch) revoke(); + return Response.json({ choices: [{ message: { content: ADVICE } }], + usage: { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 } }); + }) as typeof fetch }, + }, + } as OcxConfig; + const revoke = () => { + if (revoked) return; + revoked = true; + // Management writes replace the object; nothing mutates the copied reference in place. + const { contextSharingConsent: _revokedConsent, ...rest } = config.advisor!; + config.advisor = rest; + }; + const register = (extra: Partial = {}) => setAdvisorPlanFactory(config, async input => createAdvisorRuntimePlan({ + ...input, + ledger: createAdvisorPreflightLedger(), + budget: createAdvisorTaskBudget(join(root, "budget.sqlite")), + ...extra, + })); + // The Advisor self-fetches over loopback; re-enter the real data plane so the expert provider runs. + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/v1/chat/completions")) { + return await handleChatCompletions(new Request(url, init), config, { model: "", provider: "" }); + } + throw new Error("unexpected external fetch: " + url); + }) as typeof fetch; + return { config, workerBodies, expertBodies, revoke, register, revoked: () => revoked }; +} + +/** The real Messages request, with orientation evidence after the latest user turn. */ +function messagesRequest(): Request { + return new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "worker/m", max_tokens: 100, stream: false, messages: [ + { role: "user", content: [{ type: "text", text: "repair the token store" }, + { type: "tool_result", tool_use_id: "call_1", content: "3 tests failed" }] }, + ] }), + signal: AbortSignal.timeout(10_000), + }); +} + +// Regression: the plan used to take its activity store from the replay view. Stores are keyed by +// config identity, and the management route reads the live config's store, so a consultation made +// through /v1/messages was invisible in the dashboard and the CLI activity feed. +test("/v1/messages records its consultation in the management-visible activity feed", async () => { + const release = acquireOwnedSpendHome(); + const fixture = translatedAdvisorFixture(); + fixture.register(); + try { + const response = await handleClaudeMessages(messagesRequest(), + fixture.config, { model: "", provider: "" }, { requestId: "advisor-messages-activity", start: Date.now() }); + await response.text(); + expect(response.status).toBe(200); + expect(fixture.expertBodies).toHaveLength(1); + expect(fixture.expertBodies[0]!.url).toBe("https://expert.test/v1/chat/completions"); + + const feed = await managementActivity(fixture.config); + expect(feed.entries).toHaveLength(1); + expect(feed.entries[0]).toMatchObject({ trigger: "preflight", status: "ok", advisorModel: "expert/worker" }); + // The feed's record is the consultation whose advice reached the worker. + expect(JSON.stringify(fixture.workerBodies[0])).toContain(ADVICE); + } finally { setAdvisorPlanFactory(fixture.config, null); release(); } +}); + +// Consent is rechecked before the outbound hop. A revocation in the claim-to-dispatch window +// blocks the transfer; the worker still runs without advisory context. +test("revocation after the view was copied but before dispatch blocks the /v1/messages consultation", async () => { + const release = acquireOwnedSpendHome(); + const fixture = translatedAdvisorFixture(); + fixture.register({ afterPreflightClaim: fixture.revoke }); + try { + const response = await handleClaudeMessages(messagesRequest(), + fixture.config, { model: "", provider: "" }, { requestId: "advisor-messages-revoke-before", start: Date.now() }); + await response.text(); + expect(fixture.revoked()).toBe(true); + expect(response.status).toBe(200); + expect(fixture.expertBodies).toHaveLength(0); + expect(fixture.workerBodies).toHaveLength(1); + expect(JSON.stringify(fixture.workerBodies[0])).not.toContain(ADVICE); + } finally { setAdvisorPlanFactory(fixture.config, null); release(); } +}); + +// The documented boundary: once dispatched, a revocation neither cancels the consultation nor +// retracts context already sent. This is what the docs now say, instead of promising a retraction. +test("revocation after dispatch does not cancel the /v1/messages consultation", async () => { + const release = acquireOwnedSpendHome(); + const fixture = translatedAdvisorFixture({ revokeOnDispatch: true }); + fixture.register(); + try { + const response = await handleClaudeMessages(messagesRequest(), + fixture.config, { model: "", provider: "" }, { requestId: "advisor-messages-revoke-after", start: Date.now() }); + await response.text(); + expect(fixture.revoked()).toBe(true); + expect(response.status).toBe(200); + // Dispatched: the context left the proxy, and the consultation completed. + expect(fixture.expertBodies).toHaveLength(1); + expect(JSON.stringify(fixture.workerBodies[0])).toContain(ADVICE); + } finally { setAdvisorPlanFactory(fixture.config, null); release(); } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 79f6f670799..0e9b91d5c6e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -22,6 +22,7 @@ "advisor-caller-policy-routing.test.ts": "advisor", "advisor-caller-policy-host-key.test.ts": "advisor", "advisor-caller-policy-self-fetch.test.ts": "advisor", + "advisor-messages-replay-config.test.ts": "advisor", "graceful-max-output-tokens.test.ts": "responses", "graceful-max-output-tokens-exclusions.test.ts": "responses", "windows-config-root-async-writers.test.ts": "windows", diff --git a/tests/helpers/messages-overflow-differential.ts b/tests/helpers/messages-overflow-differential.ts index 94ba3473029..d1c0e796f7d 100644 --- a/tests/helpers/messages-overflow-differential.ts +++ b/tests/helpers/messages-overflow-differential.ts @@ -26,8 +26,8 @@ function replaceOnce(source: string, needle: string, replacement: string): strin for (const path of changed) { let source = base ? git("show", `72e80d280:${path}`) : readFileSync(repoPath(path), "utf8"); if (path === "src/server/claude-messages.ts") source = replaceOnce(source, - "const upstream = await handleResponses(internalReq, {", - "const upstream = globalThis.__messagesReplayResponse ? globalThis.__messagesReplayResponse() : await handleResponses(internalReq, {"); + "const upstream = await handleResponses(internalReq, ", + "const upstream = globalThis.__messagesReplayResponse ? globalThis.__messagesReplayResponse() : await handleResponses(internalReq, "); if (path === "src/server/claude-messages.ts") source = replaceOnce(source, "message = await collectAnthropicMessage(anthropicSse,", "if (globalThis.__messagesCollectorStream) await anthropicSse.cancel();\n message = await collectAnthropicMessage(globalThis.__messagesCollectorStream ?? anthropicSse,"); diff --git a/tests/helpers/messages-proxy-refusal-case.ts b/tests/helpers/messages-proxy-refusal-case.ts index 0d03d70f10b..5a44b2c0f58 100644 --- a/tests/helpers/messages-proxy-refusal-case.ts +++ b/tests/helpers/messages-proxy-refusal-case.ts @@ -48,8 +48,8 @@ Bun.plugin({ name: "Messages refusal boundary fixture", setup(build) { build.onLoad({ filter: filters.upstream }, ({ path }) => { hooks.upstreamLoads++; return { loader: "ts", contents: replaceOnce(readOriginal(path), - "const upstream = await handleResponses(internalReq, {", - "const upstream = globalThis.__localRefusalReply ? (globalThis.__fixtureHooks.upstreamHits++, globalThis.__localRefusalReply) : await handleResponses(internalReq, {") }; + "const upstream = await handleResponses(internalReq, ", + "const upstream = globalThis.__localRefusalReply ? (globalThis.__fixtureHooks.upstreamHits++, globalThis.__localRefusalReply) : await handleResponses(internalReq, ") }; }); build.onLoad({ filter: filters.native }, ({ path }) => { hooks.nativeLoads++;