Ctrl K
ComunidadGuías…

test/claude-sdk-options.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
var test = require("node:test");
var assert = require("node:assert");
var fs = require("node:fs");
var path = require("node:path");

var claudeAdapter = require("../lib/yoke/adapters/claude");
var buildWorkerQueryOptions = claudeAdapter.contractTestKit.buildWorkerQueryOptions;
var normalizeEvent = claudeAdapter.contractTestKit.normalizeEvent;

// --- perTaskStopAffordance -------------------------------------------------
// Clay renders a per-task Stop control, so it must declare the affordance.
// The CLI fails CLOSED without it: an interrupt would also kill every running
// background task.

test("the OS-user worker path declares the per-task stop affordance", function () {
  var options = buildWorkerQueryOptions({}, {}, "/tmp/work");
  assert.strictEqual(options.perTaskStopAffordance, true);
});

test("the affordance is declared even for queries with no dialog support", function () {
  // supportedDialogKinds is conditional; the affordance must not be, or an
  // interrupt on those sessions would silently kill background tasks.
  var options = buildWorkerQueryOptions({}, {}, "/tmp/work");
  assert.strictEqual(options.supportedDialogKinds, undefined);
  assert.strictEqual(options.perTaskStopAffordance, true);
});

test("both query paths declare the affordance identically", function () {
  // First-attached-client wins on multi-client sessions, so the in-process and
  // worker paths disagreeing would make Stop's behavior depend on which one
  // connected first. Pin both against the source.
  var source = fs.readFileSync(
    path.join(__dirname, "..", "lib", "yoke", "adapters", "claude.js"),
    "utf8"
  );
  var assignments = source.match(/perTaskStopAffordance = PER_TASK_STOP_AFFORDANCE/g) || [];
  assert.strictEqual(assignments.length, 2, "in-process and worker paths both assign it");
  assert.ok(
    /var PER_TASK_STOP_AFFORDANCE = true;/.test(source),
    "both read the same constant so they cannot drift apart"
  );
});

test("the worker receives the affordance as a serializable value", function () {
  // buildWorkerQueryOptions output crosses process IPC as msg.options.
  var options = buildWorkerQueryOptions({}, {}, "/tmp/work");
  var roundTripped = JSON.parse(JSON.stringify(options));
  assert.strictEqual(roundTripped.perTaskStopAffordance, true);
});

// --- systemPrompt snapshot -------------------------------------------------

test("system prompt recording stays off unless the caller opts in", function () {
  var options = buildWorkerQueryOptions({ appendSystemPrompt: "Extra rules." }, {}, "/tmp/work");
  assert.deepStrictEqual(options.systemPrompt, {
    type: "preset",
    preset: "claude_code",
    append: "Extra rules.",
    snapshot: false,
  });
  assert.strictEqual(options.systemPrompt.snapshot, false);
});

test("opting in records a preset-plus-append prompt", function () {
  var options = buildWorkerQueryOptions(
    { appendSystemPrompt: "Extra rules.", systemPromptSnapshot: true },
    {},
    "/tmp/work"
  );
  assert.deepStrictEqual(options.systemPrompt, {
    type: "preset",
    preset: "claude_code",
    append: "Extra rules.",
    snapshot: true,
  });
});

test("Clay's live session prompt is never recorded", function () {
  // getSessionSystemPrompt (project.js) recomposes from sticky notes, pair
  // role, and the Capsule catalog, and three call sites force a mid-session
  // re-render. Recording it would pin the first render until compaction.
  var bridge = fs.readFileSync(path.join(__dirname, "..", "lib", "sdk-bridge.js"), "utf8");
  var start = bridge.indexOf("var queryOpts = {");
  assert.ok(start !== -1, "the main session query options must still be built here");
  var block = bridge.slice(start, start + 2000);
  assert.ok(block.indexOf("appendSystemPrompt:") !== -1, "the session prompt is an append");
  assert.ok(
    block.indexOf("systemPromptSnapshot") === -1,
    "the live session path must not opt into system-prompt recording"
  );
});

test("a bare string prompt is promoted to the custom form the SDK can record", function () {
  // The SDK cannot record a bare string; it needs { type: 'custom', ... }.
  var source = fs.readFileSync(
    path.join(__dirname, "..", "lib", "yoke", "adapters", "claude.js"),
    "utf8"
  );
  var start = source.indexOf("function applySystemPromptSnapshot");
  var end = source.indexOf("function getSharedSkillPlugins");
  assert.ok(start !== -1 && end > start, "applySystemPromptSnapshot must exist");
  var apply = new Function(
    source.slice(start, end) + "\nreturn applySystemPromptSnapshot;"
  )();

  assert.deepStrictEqual(
    apply("You are a title generator.", true),
    { type: "custom", prompt: "You are a title generator.", snapshot: true }
  );
  assert.deepStrictEqual(
    apply(["line one", "line two"], true),
    { type: "custom", prompt: ["line one", "line two"], snapshot: true }
  );
  assert.deepStrictEqual(
    apply({ type: "preset", preset: "claude_code", append: "x" }, true),
    { type: "preset", preset: "claude_code", append: "x", snapshot: true }
  );
  // Explicitly opt out of the SDK default for live prompts.
  assert.deepStrictEqual(apply("plain", false), { type: "custom", prompt: "plain", snapshot: false });
  assert.deepStrictEqual(apply("plain", undefined), { type: "custom", prompt: "plain", snapshot: false });
  assert.strictEqual(apply(null, true), null);
});

test("applying the snapshot flag does not mutate the caller's prompt object", function () {
  var source = fs.readFileSync(
    path.join(__dirname, "..", "lib", "yoke", "adapters", "claude.js"),
    "utf8"
  );
  var apply = new Function(
    source.slice(
      source.indexOf("function applySystemPromptSnapshot"),
      source.indexOf("function getSharedSkillPlugins")
    ) + "\nreturn applySystemPromptSnapshot;"
  )();
  var original = { type: "preset", preset: "claude_code", append: "x" };
  apply(original, true);
  assert.strictEqual(original.snapshot, undefined);
});

// --- usage pass-through ----------------------------------------------------

test("the adapter forwards modelUsage whole so new SDK fields survive", function () {
  var events = normalizeEvent({
    type: "result",
    subtype: "success",
    total_cost_usd: 0.5,
    duration_ms: 10,
    usage: { input_tokens: 5, output_tokens: 7 },
    modelUsage: { "claude-opus-5": { outputTokens: 420, thinkingTokens: 101, costBasis: "list" } },
  });
  var result = Array.isArray(events) ? events[0] : events;
  assert.strictEqual(result.modelUsage["claude-opus-5"].thinkingTokens, 101);
  assert.strictEqual(result.modelUsage["claude-opus-5"].costBasis, "list");
});

test("the usage panel sums thinking tokens across every model in the turn", function () {
  // A single turn's modelUsage can hold several models (verified live: a haiku
  // internal call alongside the opus main loop). Reading only the first entry
  // would report 0 thinking tokens while the main model spent 101.
  var source = fs.readFileSync(
    path.join(__dirname, "..", "lib", "public", "modules", "app-panels.js"),
    "utf8"
  );
  var start = source.indexOf("export function accumulateContext");
  var end = source.indexOf("// contextView:");
  assert.ok(start !== -1 && end > start, "accumulateContext must be present");
  var body = source.slice(start, end).replace("export function", "function");
  // Stub the module-level collaborators the extracted function touches.
  var accumulate = new Function(
    "store", "resolveContextWindow", "updateContextPanel", "contextData",
    body + "\nreturn accumulateContext;"
  );

  var contextData = {
    contextWindow: 0, currentInput: null, maxOutputTokens: 0, model: "-", cost: 0, input: 0,
    output: 0, cacheRead: 0, cacheWrite: 0, turns: 0, thinking: null, costBasis: null,
  };
  var fn = accumulate(
    { get: function () { return null; } },
    function (m, w) { return w || 0; },
    function () {},
    contextData
  );

  fn(0.1, { output_tokens: 433 }, {
    "claude-haiku-4-5": { outputTokens: 13, thinkingTokens: 0, costBasis: "list" },
    "claude-opus-5": { outputTokens: 420, thinkingTokens: 101, costBasis: "list" },
  }, null);
  assert.strictEqual(contextData.thinking, 101, "thinking is summed across models");
  assert.strictEqual(contextData.costBasis, "list");

  // Models priced differently must not report whichever came first.
  fn(0.2, { output_tokens: 10 }, {
    "a": { outputTokens: 1, thinkingTokens: 2, costBasis: "list" },
    "b": { outputTokens: 1, thinkingTokens: 3, costBasis: "managed" },
  }, null);
  assert.strictEqual(contextData.thinking, 5);
  assert.strictEqual(contextData.costBasis, "mixed");

  // Older CLI builds omit both fields entirely: stay null so the rows hide
  // instead of claiming a confident zero.
  fn(0.3, { output_tokens: 10 }, { "old": { outputTokens: 9 } }, null);
  assert.strictEqual(contextData.thinking, null);
  assert.strictEqual(contextData.costBasis, null);
});

test("Codex context panel uses only valid snapshots, preserves zero, and clears restored invalid state", function () {
  var source = fs.readFileSync(
    path.join(__dirname, "..", "lib", "public", "modules", "app-panels.js"),
    "utf8"
  );
  var start = source.indexOf("export function accumulateContext");
  var end = source.indexOf("// contextView:");
  var body = source.slice(start, end).replace("export function", "function");
  var contextData = {
    contextWindow: 1000000, currentInput: null, maxOutputTokens: 0, model: "gpt-5.6", cost: 0, input: 0,
    output: 0, cacheRead: 0, cacheWrite: 0, turns: 0, thinking: null, costBasis: null,
  };
  var panelStoreState = { currentVendor: "codex", richContextUsage: { totalTokens: 900, maxTokens: 1000 } };
  var panelStore = {
    get: function (key) { return panelStoreState[key]; },
    set: function (patch) { Object.assign(panelStoreState, patch); },
  };
  var fn = new Function(
    "store", "resolveContextWindow", "updateContextPanel", "contextData",
    body + "\nreturn accumulateContext;"
  )(
    panelStore,
    function (m, w) { return w || 0; },
    function () {},
    contextData
  );
  var render = fn;
  fn = function(cost, usage, models, lastInput) {
    var normalized = require('../lib/yoke/usage').clientEvent({ type: 'result', usage: usage, modelUsage: models, lastStreamInputTokens: lastInput }, 'codex');
    return render(cost, normalized.usage, normalized.modelUsage, lastInput, normalized.context);
  };
  fn(0, { input_tokens: 739000, cache_read_input_tokens: 258000 }, {
    gpt: { contextWindow: 1000000, contextSnapshot: { valid: false } },
  }, null);
  assert.equal(panelStoreState.richContextUsage, null, "an invalid persisted snapshot clears stale rich context");
  assert.equal(contextData.input, 997000, "billing usage remains available to the usage panel");
  assert.equal(contextData.currentInput, null, "billing usage does not become Codex occupancy");
  fn(0, { input_tokens: 739000, cache_read_input_tokens: 258000 }, {
    gpt: { contextWindow: 1000000, contextSnapshot: {
      valid: true, inputTokens: 0, contextWindow: 1000000, turnId: "turn-1",
    } },
  }, null);
  assert.equal(contextData.currentInput, 0, "a valid zero snapshot remains visible");
  assert.equal(contextData.contextWindow, 1000000);
  fn(0, null, {
    gpt: { contextWindow: 1000000, contextSnapshot: { valid: false } },
  }, null);
  assert.equal(contextData.currentInput, null, "history replay clears an unavailable snapshot");
  assert.match(source, /richContext\.usedTokens[\s\S]*richContext\.windowTokens/);
  assert.match(source, /typeof contextData\.currentInput === "number"/);
  assert.match(source, /context.source === "last_turn"/);
  assert.doesNotMatch(source, /currentVendor.*===.*"codex"/);
});

test("the usage panel markup has hidden rows for both optional fields", function () {
  var html = fs.readFileSync(path.join(__dirname, "..", "lib", "public", "index.html"), "utf8");
  assert.ok(/id="context-thinking-row"[^>]*class="[^"]*hidden/.test(html)
    || /class="usage-row hidden" id="context-thinking-row"/.test(html),
    "the thinking row starts hidden");
  assert.ok(/class="usage-row hidden" id="context-cost-basis-row"/.test(html),
    "the cost-basis row starts hidden");
  assert.ok(html.indexOf('id="context-thinking"') !== -1);
  assert.ok(html.indexOf('id="context-cost-basis"') !== -1);
});