test/claude-startup-diagnostics.test.js
Rama main · réplica de lectura del repositorio original.
var test = require("node:test");
var assert = require("node:assert/strict");
var fs = require("fs");
var normalizeEvent = require("../lib/yoke/adapters/claude").contractTestKit.normalizeEvent;
var attachMessageProcessor = require("../lib/sdk-message-processor").attachMessageProcessor;
// Coverage for SDK diagnostics explicitly in scope: startup_failure_reason
// (0.3.280), plugin_errors (0.3.283), and the tool_use_result markers
// detachedToolCall / structuredContentOmitted, verified against the real
// installed SDK's own type definitions (node_modules/@anthropic-ai/
// claude-agent-sdk/sdk.d.ts), not guessed from the changelog prose alone.
function fixture() {
var recorded = [];
var sm = { sendAndRecord: function (session, obj) { recorded.push(obj); }, sendToSession: function () {}, broadcastSessionList: function () {} };
var mp = attachMessageProcessor({
sm: sm, send: function () {}, onProcessingChanged: function () {},
discoverSkillDirs: function () { return []; },
mergeSkills: function (a) { return new Set(a || []); },
getSessionSkillOptions: function () { return {}; },
});
var session = {
messageUUIDs: [], history: [], blocks: {}, sentToolResults: {},
pendingPermissions: {}, pendingElicitations: {}, pendingAskUser: {},
activeTaskToolIds: {}, taskIdMap: {}, turnCount: 0, responsePreview: "",
skillNames: [], isProcessing: true,
};
return { mp: mp, session: session, recorded: recorded };
}
test("normalizeEvent forwards plugin_errors from a system/init message", function () {
var events = normalizeEvent({ type: "system", subtype: "init", plugin_errors: [{ path: "/bad/plugin" }] });
var event = Array.isArray(events) ? events[0] : events;
assert.deepEqual(event.pluginErrors, [{ path: "/bad/plugin" }]);
});
test("a failed plugin directory is surfaced as a warning notice, not silently dropped", function () {
var f = fixture();
f.mp.processSDKMessage(f.session, {
yokeType: "init", pluginErrors: [{ path: "/repo/.claude/plugins/broken" }], skills: [],
});
var notice = f.recorded.find(function (m) { return m.type === "informational"; });
assert.ok(notice, "a plugin load failure must reach the existing informational notice pathway");
assert.equal(notice.level, "warning");
assert.match(notice.content, /\/repo\/\.claude\/plugins\/broken/);
});
test("normalizeEvent forwards startup_failure_reason from an error result", function () {
var events = normalizeEvent({ type: "result", is_error: true, startup_failure_reason: "provider_not_allowed" });
var event = Array.isArray(events) ? events[0] : events;
assert.equal(event.startupFailureReason, "provider_not_allowed");
});
test("a named startup failure reaches the user through the existing error/done pathway", function () {
var f = fixture();
f.mp.processSDKMessage(f.session, { yokeType: "result", is_error: true, startupFailureReason: "provider_not_allowed" });
var errorMsg = f.recorded.find(function (m) { return m.type === "error"; });
assert.ok(errorMsg, "a startup failure must produce a visible error, not a silent stall");
assert.match(errorMsg.text, /provider_not_allowed/);
assert.ok(f.recorded.some(function (m) { return m.type === "done"; }), "the turn must still end, not hang forever");
assert.equal(f.session.isProcessing, false);
});
test("a detached tool call's placeholder is never treated as that tool's final, permanent result", function () {
var f = fixture();
f.session.activeTaskToolIds = { "tool-1": true };
// The placeholder arrives first, exactly as sdk.d.ts describes: "in place
// of its Output: the call is still running, and its result reaches the
// model in a later turn" under the same tool_use_id.
f.mp.processSDKMessage(f.session, normalizeEvent({
type: "user",
tool_use_result: { detachedToolCall: true },
message: { content: [{ type: "tool_result", tool_use_id: "tool-1", content: "" }] },
}));
var placeholder = f.recorded.find(function (m) { return m.type === "tool_result"; });
assert.ok(placeholder, "the placeholder is still shown, not swallowed");
assert.equal(placeholder.detached, true, "the client must be told this is not the final answer yet");
assert.equal(f.session.sentToolResults["tool-1"], undefined,
"marking it sent would make the dedup guard below silently drop the real result");
assert.equal(f.session.activeTaskToolIds["tool-1"], true, "not cleared: the call has not actually finished");
// The real result for the SAME tool_use_id arrives in a later turn.
f.recorded.length = 0;
f.mp.processSDKMessage(f.session, normalizeEvent({
type: "user",
message: { content: [{ type: "tool_result", tool_use_id: "tool-1", content: "the real answer" }] },
}));
var real = f.recorded.find(function (m) { return m.type === "tool_result"; });
assert.ok(real, "the deferred real result must still reach the client");
assert.equal(real.content, "the real answer");
assert.equal(real.detached, undefined);
});
test("structuredContentOmitted is confirmed not applicable: Clay never reads structured tool output", function () {
// Per the installed SDK's own type definitions, tool_use_result / its
// structuredContent are "the tool's full Output object, not the string
// content sent to the model" -- a separate, unknown-typed field Clay's
// rendering path does not consume. Confirms that by construction, not
// by claim: the tool_result handling reads only block.content/.text.
var source = fs.readFileSync("lib/sdk-message-processor.js", "utf8");
var block = source.slice(source.indexOf('if (block.type === "tool_result"'));
block = block.slice(0, block.indexOf("sendAndRecord(session, toolResultMsg)"));
assert.equal(/structuredContent/.test(block), false,
"no code path reads structuredContent, so its omission cannot be misrepresented as empty output here");
assert.match(block, /block\.content/, "only the model-facing text content is read");
});