test/claude-sdk-options.test.js
Rama main · réplica de lectura del repositorio original.
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);
});