mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
execute_code no longer POSTs /v1/exec behind a shared service key. It leases a Hanzo sandbox (cloud /v1/sandboxes) with the CALLER's own hanzo.id bearer, runs the program there, and reports what it printed and what it wrote. The session IS the sandbox. `session_id` is the sandbox id, the artifact directory is its workdir, an expired session is a lease the reaper ended. There is no session table here because there is no second object to keep in sync. Why the key had to be deleted rather than fixed ----------------------------------------------- A shared service key IS custom auth, and it is also why that surface could not bill: it carries no tenant, so cloud could not tell whose org a run belonged to — apps/exec's own header says so. With the user's bearer the run lands on the account that ran it, because the account is the only thing the call carries. It was never even wired: `EnvVar.CODE_API_KEY` does not exist in the shipped @hanzochat/agents, so `authFields: [undefined]` at every call site and setting the env var was a silent no-op. Deleted at all ~20 sites, plus the guard in loadAuthValues that existed only to survive the `undefined`, plus the client's key-entry path in the tool-auth contract (`fieldsMap[execute_code]` is now `[]` and verifyToolAuth never consults the credential store for it). What the tool does now ---------------------- One round trip. The program rides the exec channel's stdin, so the source is never quoted into a command line. Its stdout/stderr are redirected to files, so the command's own stdout carries only four framed lines — exit code, then base64 of out, err and the new-file list. That is unforgeable by construction: a delimiter would let a program that printed the delimiter name files it never wrote. Scratch lives in /tmp, never in /mnt/data, so the artifact scan cannot mistake this tool's own source for a user's output. The language menu is measured against the sandbox image rather than inherited: `d`, `f90` and `r` are dropped (no compiler), `rb` added, `ts` runs on bun. Files in and out ride the same three verbs: upload is POST /:id/fs, download is GET /:id/fs, and "is this session still alive?" is GET /:id — one question where there used to be a `lastModified` fetch and a 23-hour freshness rule that only together approximated it. PTC is declared and not executable: it is a different protocol (it suspends the run and resumes from a continuation token) and cloud answers it 501, so offering a tool that fails mid-run is worse than not offering one. De-branding: user-facing prose first, wire second. The `LIBRECHAT_TEST_RUN_HOOK` env var and the `@librechat/*` requires are renamed because BOTH ends live in this repo. The `application/vnd.librechat.*` artifact MIMEs are NOT renamed — they are identifiers on the wire, no user reads them, and a rename needs a migration story it would not buy anything. NOTICE and LICENSE are untouched.
1577 lines
49 KiB
JavaScript
1577 lines
49 KiB
JavaScript
/**
|
|
* In-process fake LLM for credential-free e2e tests. Loaded by `@hanzochat/api`'s
|
|
* `createRun` via the `CHAT_TEST_RUN_HOOK` env var (set by the mock
|
|
* Playwright config and the `--profile=mock` recorder), it swaps the run's model
|
|
* for the agents package's own `FakeChatModel` through
|
|
* `run.Graph.overrideTestModel(...)`.
|
|
*
|
|
* This exercises the real `Run.create` -> graph -> tool-node pipeline end to end
|
|
* without a live provider or a standalone HTTP mock server: responses are decided
|
|
* from the conversation and the agents' advertised tools.
|
|
*/
|
|
const { FakeChatModel } = require('@hanzochat/agents');
|
|
const { ChatGenerationChunk } = require('@langchain/core/outputs');
|
|
const { AIMessageChunk } = require('@langchain/core/messages');
|
|
|
|
const MOCK_REPLY = process.env.MOCK_LLM_REPLY || 'E2E mock reply: pong';
|
|
const CHUNK_DELAY_MS = Number(process.env.MOCK_LLM_CHUNK_DELAY_MS) || 10;
|
|
|
|
const CREATE_SKILL_MARKER = 'E2E_CREATE_SKILL:';
|
|
const EDIT_SKILL_MARKER = 'E2E_EDIT_SKILL:';
|
|
const ASSERT_SKILLS_MARKER = 'E2E_ASSERT_SKILLS:';
|
|
const ASSERT_MANUAL_SKILL_MARKER = 'E2E_ASSERT_MANUAL_SKILL:';
|
|
const INVOKE_SKILL_MARKER = 'E2E_INVOKE_SKILL:';
|
|
const ASSERT_PROVIDER_FILE_MARKER = 'E2E_ASSERT_PROVIDER_FILE:';
|
|
const ASSERT_AGENT_CONTEXT_MARKER = 'E2E_ASSERT_AGENT_CONTEXT:';
|
|
const ASSERT_QUOTE_MARKER = 'E2E_ASSERT_QUOTE:';
|
|
const REPLY_MARKER = 'E2E_REPLY:';
|
|
const COUNTED_REPLY_MARKER = 'E2E_COUNTED_REPLY:';
|
|
const SLOW_REPLY_MARKER = 'E2E_SLOW_REPLY:';
|
|
const SLOW_COUNTED_REPLY_MARKER = 'E2E_SLOW_COUNTED_REPLY:';
|
|
const STEER_TOOL_REPLY_MARKER = 'E2E_STEER_TOOL_REPLY:';
|
|
const RESUME_ICON_REPLY_MARKER = 'E2E_RESUME_ICON_REPLY:';
|
|
const FORCED_ERROR_MARKER = 'E2E_FORCED_ERROR:';
|
|
const MARKDOWN_REPLY_MARKER = 'E2E_MARKDOWN_REPLY';
|
|
const BACKGROUND_DISPATCH_MARKER = 'E2E_BACKGROUND_DISPATCH:';
|
|
const BACKGROUND_COLLECT_MARKER = 'E2E_BACKGROUND_COLLECT:';
|
|
const TOOL_APPROVAL_MARKER = 'E2E_TOOL_APPROVAL:';
|
|
const TOOL_APPROVAL_BATCH_MARKER = 'E2E_TOOL_APPROVAL_BATCH:';
|
|
const TOOL_APPROVAL_RESTRICTED_MARKER = 'E2E_TOOL_APPROVAL_RESTRICTED:';
|
|
const TOOL_APPROVAL_REWRITE_MARKER = 'E2E_TOOL_APPROVAL_REWRITE:';
|
|
const HANDOFF_MARKER = 'E2E_HANDOFF:';
|
|
const HANDOFF_TOOL_PREFIX = 'lc_transfer_to_';
|
|
const CREATE_FILE_AUTHORING_FINAL_TEXT = 'E2E file authoring complete';
|
|
const EDIT_FILE_AUTHORING_FINAL_TEXT = 'E2E file edit complete';
|
|
const SKILL_ASSERTION_FINAL_TEXT = 'E2E skill assertion passed';
|
|
const MANUAL_SKILL_ASSERTION_FINAL_TEXT = 'E2E manual skill assertion passed';
|
|
const SKILL_TOOL_ASSERTION_FINAL_TEXT = 'E2E skill tool assertion passed';
|
|
const PROVIDER_FILE_ASSERTION_FINAL_TEXT = 'E2E provider file assertion passed';
|
|
const AGENT_CONTEXT_ASSERTION_FINAL_TEXT = 'E2E agent context assertion passed';
|
|
const QUOTE_ASSERTION_FINAL_TEXT = 'E2E quote assertion passed';
|
|
const STEER_TOOL_FINAL_TEXT = 'E2E steer tool reply done';
|
|
const STEER_TOOL_NAME_PREFIX = 'remember_fact';
|
|
const SLOW_CHUNK_DELAY_MS = Number(process.env.MOCK_LLM_SLOW_CHUNK_DELAY_MS) || 35;
|
|
const SLOW_REPLY_CHUNKS = 160;
|
|
const RESUME_ICON_CHUNK_DELAY_MS = Number(process.env.MOCK_LLM_RESUME_ICON_CHUNK_DELAY_MS) || 60;
|
|
const RESUME_ICON_REPLY_CHUNKS = 240;
|
|
const CREATE_FILE_TOOL_NAME = 'create_file';
|
|
const EDIT_FILE_TOOL_NAME = 'edit_file';
|
|
const BASH_TOOL_NAME = 'bash_tool';
|
|
const SKILL_TOOL_NAME = 'skill';
|
|
const CREATE_SKILL_TOOL_CALL_ID = 'call_e2e_create_skill';
|
|
const EDIT_SKILL_TOOL_CALL_ID = 'call_e2e_edit_skill';
|
|
const BACKGROUND_TOOL_NAME = 'slow_echo_mcp_e2e-memory';
|
|
const CHECK_BACKGROUND_TASK_TOOL_NAME = 'check_background_task';
|
|
const APPROVAL_TOOL_NAME = 'approval_probe_mcp_e2e-memory';
|
|
const APPROVAL_TOOL_CALL_PREFIX = 'call_e2e_approval_';
|
|
const BACKGROUND_DISPATCH_TOOL_CALL_ID = 'call_e2e_background_dispatch';
|
|
const BACKGROUND_COLLECT_TOOL_CALL_ID = 'call_e2e_background_collect';
|
|
const MODEL_SPEC_ACCESSIBLE_SKILL = 'e2e-model-spec-allowed';
|
|
const DEPLOYMENT_SKILL_NAME = 'e2e-deployment-skill';
|
|
const ALWAYS_APPLY_BODY_MARKER = 'E2E_ALWAYS_APPLY_BODY_MARKER';
|
|
const DEPLOYMENT_SKILL_BODY_MARKER = 'E2E deployment skill loaded through Playwright';
|
|
const SKILL_DESCRIPTION =
|
|
'Use this skill to verify skill file authoring in mock end-to-end tests.';
|
|
const EDITED_SKILL_DESCRIPTION =
|
|
'Use this edited skill to verify skill file authoring in mock end-to-end tests.';
|
|
const countedReplies = new Map();
|
|
const slowCountedReplies = new Map();
|
|
|
|
function messageType(message) {
|
|
if (typeof message.getType === 'function') {
|
|
return message.getType();
|
|
}
|
|
if (typeof message._getType === 'function') {
|
|
return message._getType();
|
|
}
|
|
return message.role || message.type || '';
|
|
}
|
|
|
|
function getContentText(content) {
|
|
if (typeof content === 'string') {
|
|
return content;
|
|
}
|
|
if (!Array.isArray(content)) {
|
|
return '';
|
|
}
|
|
return content
|
|
.map((part) => {
|
|
if (typeof part === 'string') {
|
|
return part;
|
|
}
|
|
if (part && typeof part === 'object' && typeof part.text === 'string') {
|
|
return part.text;
|
|
}
|
|
return '';
|
|
})
|
|
.join('\n');
|
|
}
|
|
|
|
function getLatestUserText(messages) {
|
|
const message = getLatestUserMessage(messages);
|
|
return message ? getContentText(message.content) : '';
|
|
}
|
|
|
|
function getLatestUserMessage(messages) {
|
|
if (!Array.isArray(messages)) {
|
|
return null;
|
|
}
|
|
for (let index = messages.length - 1; index >= 0; index--) {
|
|
const message = messages[index];
|
|
if (!message) {
|
|
continue;
|
|
}
|
|
const type = messageType(message);
|
|
if (type === 'human' || type === 'user') {
|
|
return message;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getRequestedSkillName(text, marker) {
|
|
const markerIndex = text.indexOf(marker);
|
|
if (markerIndex === -1) {
|
|
return '';
|
|
}
|
|
const afterMarker = text.slice(markerIndex + marker.length);
|
|
return afterMarker.match(/[a-z0-9][a-z0-9-]*/)?.[0] ?? '';
|
|
}
|
|
|
|
function getMarkerValue(text, marker) {
|
|
const markerIndex = text.indexOf(marker);
|
|
if (markerIndex === -1) {
|
|
return '';
|
|
}
|
|
return (
|
|
text
|
|
.slice(markerIndex + marker.length)
|
|
.trim()
|
|
.split(/\s+/, 1)[0] ?? ''
|
|
);
|
|
}
|
|
|
|
function collectToolNames(agents) {
|
|
const names = new Set();
|
|
const add = (name) => {
|
|
if (typeof name === 'string' && name) {
|
|
names.add(name);
|
|
}
|
|
};
|
|
for (const agent of agents ?? []) {
|
|
if (!agent) {
|
|
continue;
|
|
}
|
|
for (const tool of agent.tools ?? []) {
|
|
add(tool?.name);
|
|
}
|
|
for (const def of agent.toolDefinitions ?? []) {
|
|
add(def?.name);
|
|
}
|
|
if (agent.toolRegistry && typeof agent.toolRegistry.keys === 'function') {
|
|
for (const name of agent.toolRegistry.keys()) {
|
|
add(name);
|
|
}
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
async function getStreamAgentView({ graph, messages, options, runManager }) {
|
|
let agentId = runManager?.metadata?.agentId ?? options?.metadata?.agentId;
|
|
let agentContext;
|
|
if (typeof agentId === 'string') {
|
|
agentContext = graph?.agentContexts?.get(agentId);
|
|
} else if (graph?.agentContexts?.size === 1) {
|
|
[agentId, agentContext] = graph.agentContexts.entries().next().value;
|
|
}
|
|
/**
|
|
* Graph.attemptInvoke intentionally sends test override models the pruned
|
|
* messages directly, bypassing the production model's systemRunnable pipe.
|
|
* Apply that agent's runnable here so assertions inspect the same complete
|
|
* prompt (system catalog plus messages) that a real provider receives.
|
|
*/
|
|
const systemRunnable = agentContext?.systemRunnable;
|
|
const promptMessages =
|
|
systemRunnable && typeof systemRunnable.invoke === 'function'
|
|
? await systemRunnable.invoke(messages)
|
|
: messages;
|
|
return {
|
|
agentId,
|
|
messages: promptMessages,
|
|
toolNames: collectToolNames(agentContext ? [agentContext] : []),
|
|
};
|
|
}
|
|
|
|
function collectPromptText(value, parts = []) {
|
|
if (value == null) {
|
|
return parts;
|
|
}
|
|
|
|
if (typeof value === 'string') {
|
|
parts.push(value);
|
|
return parts;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
collectPromptText(item, parts);
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
if (typeof value === 'object') {
|
|
for (const child of Object.values(value)) {
|
|
collectPromptText(child, parts);
|
|
}
|
|
}
|
|
|
|
return parts;
|
|
}
|
|
|
|
function collectSkillPrimeMessages(messages) {
|
|
return (messages ?? [])
|
|
.filter((message) => message?.additional_kwargs?.source === 'skill')
|
|
.map((message) => ({
|
|
name: message.additional_kwargs.skillName,
|
|
trigger: message.additional_kwargs.trigger,
|
|
content: getContentText(message.content),
|
|
}));
|
|
}
|
|
|
|
function collectProviderFileNames(value, names = new Set()) {
|
|
if (value == null) {
|
|
return names;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
collectProviderFileNames(item, names);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
if (typeof value !== 'object') {
|
|
return names;
|
|
}
|
|
|
|
if (value.type === 'input_file' && typeof value.filename === 'string') {
|
|
names.add(value.filename);
|
|
}
|
|
|
|
if (value.type === 'file' && typeof value.file?.filename === 'string') {
|
|
names.add(value.file.filename);
|
|
}
|
|
|
|
if (value.type === 'document' && typeof value.context === 'string') {
|
|
const match = value.context.match(/File:\s*"([^"]+)"/);
|
|
if (match?.[1]) {
|
|
names.add(match[1]);
|
|
}
|
|
}
|
|
|
|
for (const child of Object.values(value)) {
|
|
collectProviderFileNames(child, names);
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
function providerFileAssertionResponses({ messages, text }) {
|
|
const filename = getMarkerValue(text, ASSERT_PROVIDER_FILE_MARKER);
|
|
if (!filename) {
|
|
return null;
|
|
}
|
|
|
|
const latestUserMessage = getLatestUserMessage(messages);
|
|
const providerFileNames = collectProviderFileNames(latestUserMessage?.content);
|
|
if (providerFileNames.has(filename)) {
|
|
return {
|
|
responses: [`${PROVIDER_FILE_ASSERTION_FINAL_TEXT}: ${filename}`],
|
|
};
|
|
}
|
|
|
|
return {
|
|
responses: [
|
|
`E2E provider file assertion failed: expected ${filename}; saw ${
|
|
Array.from(providerFileNames).join(', ') || 'no provider files'
|
|
}`,
|
|
],
|
|
};
|
|
}
|
|
|
|
function agentContextAssertionResponses({ messages, text }) {
|
|
const expected = getMarkerValue(text, ASSERT_AGENT_CONTEXT_MARKER);
|
|
if (!expected) {
|
|
return null;
|
|
}
|
|
|
|
const promptText = collectPromptText(messages).join('\n');
|
|
if (promptText.includes(expected)) {
|
|
return {
|
|
responses: [`${AGENT_CONTEXT_ASSERTION_FINAL_TEXT}: ${expected}`],
|
|
};
|
|
}
|
|
|
|
return {
|
|
responses: [
|
|
`E2E agent context assertion failed: expected ${expected}; saw ${
|
|
promptText ? 'prompt context without marker' : 'no prompt context'
|
|
}`,
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Verifies the quote feature end to end: scans every user message in the prompt
|
|
* the model actually received for a Markdown blockquote line containing the
|
|
* expected token. Passing proves the excerpt was merged into the model-facing
|
|
* turn — covering both the current turn and durable re-merge of a prior quoted
|
|
* turn from history (the merge runs in `AgentClient.buildMessages`).
|
|
*/
|
|
function quoteAssertionResponses({ messages, text }) {
|
|
const expected = getMarkerValue(text, ASSERT_QUOTE_MARKER);
|
|
if (!expected) {
|
|
return null;
|
|
}
|
|
|
|
const found = (messages ?? []).some((message) => {
|
|
const type = messageType(message);
|
|
if (type !== 'human' && type !== 'user') {
|
|
return false;
|
|
}
|
|
return getContentText(message.content)
|
|
.split('\n')
|
|
.some((line) => line.startsWith('> ') && line.includes(expected));
|
|
});
|
|
|
|
if (found) {
|
|
return { responses: [`${QUOTE_ASSERTION_FINAL_TEXT}: ${expected}`] };
|
|
}
|
|
return {
|
|
responses: [`E2E quote assertion failed: no blockquote containing "${expected}" in the prompt`],
|
|
};
|
|
}
|
|
|
|
function replyResponses(text) {
|
|
if (text.includes(MARKDOWN_REPLY_MARKER)) {
|
|
return {
|
|
responses: [
|
|
[
|
|
'## E2E markdown heading',
|
|
'',
|
|
'**E2E bold text**',
|
|
'',
|
|
'- E2E list item',
|
|
'',
|
|
'```javascript',
|
|
'const e2eSyntaxHighlight = "ok";',
|
|
'```',
|
|
].join('\n'),
|
|
],
|
|
};
|
|
}
|
|
|
|
const errorName = getMarkerValue(text, FORCED_ERROR_MARKER);
|
|
if (errorName) {
|
|
return {
|
|
responses: [`E2E forced error prelude ${errorName}`],
|
|
thrownError: `E2E forced stream error ${errorName}`,
|
|
};
|
|
}
|
|
|
|
const replyName = getMarkerValue(text, REPLY_MARKER);
|
|
if (replyName) {
|
|
return {
|
|
responses: [`E2E reply ${replyName}`],
|
|
};
|
|
}
|
|
|
|
const countedName = getMarkerValue(text, COUNTED_REPLY_MARKER);
|
|
if (countedName) {
|
|
const count = (countedReplies.get(countedName) ?? 0) + 1;
|
|
countedReplies.set(countedName, count);
|
|
return {
|
|
responses: [`E2E counted reply ${countedName} #${count}`],
|
|
};
|
|
}
|
|
|
|
const slowName = getMarkerValue(text, SLOW_REPLY_MARKER);
|
|
if (slowName) {
|
|
const chunks = Array.from(
|
|
{ length: SLOW_REPLY_CHUNKS },
|
|
(_, index) => `chunk-${String(index).padStart(3, '0')}`,
|
|
).join(' ');
|
|
return {
|
|
responses: [`E2E slow reply ${slowName} ${chunks}`],
|
|
sleep: SLOW_CHUNK_DELAY_MS,
|
|
};
|
|
}
|
|
|
|
const slowCountedName = getMarkerValue(text, SLOW_COUNTED_REPLY_MARKER);
|
|
if (slowCountedName) {
|
|
const count = (slowCountedReplies.get(slowCountedName) ?? 0) + 1;
|
|
slowCountedReplies.set(slowCountedName, count);
|
|
const chunks = Array.from(
|
|
{ length: SLOW_REPLY_CHUNKS },
|
|
(_, index) => `chunk-${String(index).padStart(3, '0')}`,
|
|
).join(' ');
|
|
return {
|
|
responses: [`E2E slow counted reply ${slowCountedName} #${count} ${chunks}`],
|
|
sleep: SLOW_CHUNK_DELAY_MS,
|
|
};
|
|
}
|
|
|
|
const resumeIconName = getMarkerValue(text, RESUME_ICON_REPLY_MARKER);
|
|
if (resumeIconName) {
|
|
const chunks = Array.from(
|
|
{ length: RESUME_ICON_REPLY_CHUNKS },
|
|
(_, index) => `chunk-${String(index).padStart(3, '0')}`,
|
|
).join(' ');
|
|
return {
|
|
responses: [`E2E resume icon reply ${resumeIconName} ${chunks}`],
|
|
sleep: RESUME_ICON_CHUNK_DELAY_MS,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Attaches synthetic usage_metadata on a final empty chunk (the OpenAI
|
|
* streaming pattern) so token-usage SSE events flow end to end in mock runs.
|
|
*/
|
|
class UsageEmittingFakeChatModel extends FakeChatModel {
|
|
constructor({ resolveInvocation, resolveOnStream, sleep, ...options }) {
|
|
super({ ...options, sleep });
|
|
this.resolveInvocation = resolveInvocation;
|
|
this.resolveOnStream = resolveOnStream;
|
|
this.streamSleep = sleep ?? CHUNK_DELAY_MS;
|
|
}
|
|
|
|
async *streamScriptedResponseChunks({ response, toolCalls, runManager }) {
|
|
if (this.emitCustomEvent) {
|
|
await runManager?.handleCustomEvent('some_test_event', {
|
|
someval: true,
|
|
});
|
|
}
|
|
|
|
const chunks = response ? response.split(/(?<=\s+)|(?=\s+)/) : [];
|
|
for await (const chunk of chunks) {
|
|
await new Promise((resolve) => setTimeout(resolve, this.streamSleep));
|
|
const responseChunk = this._createResponseChunk(chunk);
|
|
yield responseChunk;
|
|
void runManager?.handleLLMNewToken(chunk);
|
|
}
|
|
|
|
if (toolCalls?.length) {
|
|
await new Promise((resolve) => setTimeout(resolve, this.streamSleep));
|
|
const toolCallChunks = toolCalls.map((toolCall, index) => ({
|
|
name: toolCall.name,
|
|
args: JSON.stringify(toolCall.args),
|
|
id: toolCall.id,
|
|
index,
|
|
type: 'tool_call_chunk',
|
|
}));
|
|
yield this._createResponseChunk('', toolCallChunks);
|
|
void runManager?.handleLLMNewToken('');
|
|
}
|
|
}
|
|
|
|
async *streamDynamicResponseChunks({ responses, options, runManager }) {
|
|
if (this.emitCustomEvent) {
|
|
await runManager?.handleCustomEvent('some_test_event', {
|
|
someval: true,
|
|
});
|
|
}
|
|
|
|
const response = responses[0] ?? '';
|
|
const chunks = response.split(/(?<=\s+)|(?=\s+)/);
|
|
for await (const chunk of chunks) {
|
|
await new Promise((resolve) => setTimeout(resolve, this.streamSleep));
|
|
|
|
if (options.thrownErrorString != null && options.thrownErrorString) {
|
|
throw new Error(options.thrownErrorString);
|
|
}
|
|
|
|
const responseChunk = this._createResponseChunk(chunk);
|
|
yield responseChunk;
|
|
void runManager?.handleLLMNewToken(chunk);
|
|
}
|
|
}
|
|
|
|
async *_streamResponseChunks(messages, options, runManager) {
|
|
let outputChars = 0;
|
|
const scriptedResponse = await this.resolveInvocation?.(messages, options, runManager);
|
|
const dynamicResponse = scriptedResponse
|
|
? null
|
|
: await this.resolveOnStream?.(messages, options, runManager);
|
|
let chunkStream;
|
|
if (scriptedResponse) {
|
|
chunkStream = this.streamScriptedResponseChunks({
|
|
response: scriptedResponse.response ?? '',
|
|
toolCalls: scriptedResponse.toolCalls,
|
|
runManager,
|
|
});
|
|
} else if (dynamicResponse) {
|
|
chunkStream = this.streamDynamicResponseChunks({
|
|
responses: dynamicResponse.responses,
|
|
options,
|
|
runManager,
|
|
});
|
|
} else {
|
|
chunkStream = super._streamResponseChunks(messages, options, runManager);
|
|
}
|
|
|
|
for await (const chunk of chunkStream) {
|
|
outputChars += typeof chunk.text === 'string' ? chunk.text.length : 0;
|
|
yield chunk;
|
|
}
|
|
const inputChars = (messages ?? []).reduce(
|
|
(sum, message) => sum + getContentText(message?.content).length,
|
|
0,
|
|
);
|
|
const input_tokens = Math.max(1, Math.ceil(inputChars / 4));
|
|
const output_tokens = Math.max(1, Math.ceil(outputChars / 4));
|
|
yield new ChatGenerationChunk({
|
|
text: '',
|
|
message: new AIMessageChunk({
|
|
content: '',
|
|
usage_metadata: { input_tokens, output_tokens, total_tokens: input_tokens + output_tokens },
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
function overrideModel({
|
|
graph,
|
|
responses,
|
|
sleep,
|
|
toolCalls,
|
|
thrownError,
|
|
resolveInvocation,
|
|
resolveOnStream,
|
|
}) {
|
|
if (!thrownError) {
|
|
graph.overrideModel = new UsageEmittingFakeChatModel({
|
|
responses,
|
|
sleep: sleep ?? CHUNK_DELAY_MS,
|
|
emitCustomEvent: true,
|
|
toolCalls,
|
|
resolveInvocation,
|
|
resolveOnStream,
|
|
});
|
|
return;
|
|
}
|
|
|
|
class ThrowingFakeChatModel extends FakeChatModel {
|
|
async *_streamResponseChunks(messages, options, runManager) {
|
|
yield* super._streamResponseChunks(
|
|
messages,
|
|
{ ...options, thrownErrorString: thrownError },
|
|
runManager,
|
|
);
|
|
}
|
|
}
|
|
|
|
graph.overrideModel = new ThrowingFakeChatModel({
|
|
responses,
|
|
sleep: sleep ?? CHUNK_DELAY_MS,
|
|
emitCustomEvent: true,
|
|
toolCalls,
|
|
});
|
|
}
|
|
|
|
function parseSkillAssertion(text, agentId) {
|
|
const markerValue = getMarkerValue(text, ASSERT_SKILLS_MARKER);
|
|
const sections = markerValue
|
|
.split(';')
|
|
.map((section) => section.trim())
|
|
.filter(Boolean);
|
|
const isAgentScoped = sections.some((section) => section.includes('='));
|
|
let entriesValue = markerValue;
|
|
if (isAgentScoped) {
|
|
// Parallel agents receive a per-run `____N` suffix while the request and
|
|
// persisted Agent Builder state retain the stable agent id.
|
|
const persistedAgentId =
|
|
typeof agentId === 'string' ? agentId.replace(/____\d+$/, '') : agentId;
|
|
const prefixes = [`${agentId}=`, `${persistedAgentId}=`];
|
|
const scopedSection = sections.find((section) =>
|
|
prefixes.some((prefix) => section.startsWith(prefix)),
|
|
);
|
|
if (!scopedSection) {
|
|
return {
|
|
required: [],
|
|
requiredBodies: [],
|
|
forbidden: [],
|
|
error: `no skill assertion was configured for agent ${agentId ?? 'unknown'}`,
|
|
};
|
|
}
|
|
const prefix = prefixes.find((candidate) => scopedSection.startsWith(candidate));
|
|
entriesValue = scopedSection.slice(prefix.length);
|
|
}
|
|
|
|
const entries = entriesValue
|
|
.split(',')
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
return entries.reduce(
|
|
(assertion, entry) => {
|
|
if (entry.startsWith('!')) {
|
|
const name = entry.slice(1);
|
|
if (name) {
|
|
assertion.forbidden.push(name);
|
|
}
|
|
return assertion;
|
|
}
|
|
if (entry.startsWith('*')) {
|
|
const name = entry.slice(1);
|
|
if (name) {
|
|
assertion.required.push(name);
|
|
assertion.requiredBodies.push(name);
|
|
}
|
|
return assertion;
|
|
}
|
|
assertion.required.push(entry);
|
|
return assertion;
|
|
},
|
|
{ required: [], requiredBodies: [], forbidden: [], error: undefined },
|
|
);
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function promptHasSkillCatalogEntry(promptText, skillName) {
|
|
if (!promptText.includes('## Available Skills')) {
|
|
return false;
|
|
}
|
|
return new RegExp(`(?:^|\\n)- ${escapeRegExp(skillName)}(?::|\\s*(?:\\n|$))`, 'm').test(
|
|
promptText,
|
|
);
|
|
}
|
|
|
|
function expectedSkillBodyMarker(skillName) {
|
|
if (skillName === MODEL_SPEC_ACCESSIBLE_SKILL) {
|
|
return ALWAYS_APPLY_BODY_MARKER;
|
|
}
|
|
if (skillName === DEPLOYMENT_SKILL_NAME) {
|
|
return DEPLOYMENT_SKILL_BODY_MARKER;
|
|
}
|
|
return `# ${skillName}`;
|
|
}
|
|
|
|
function skillAssertionResponses({ messages, assertion, toolNames }) {
|
|
const failures = [];
|
|
if (assertion.error) {
|
|
failures.push(assertion.error);
|
|
}
|
|
const promptText = collectPromptText(messages).join('\n');
|
|
const skillPrimeMessages = collectSkillPrimeMessages(messages);
|
|
|
|
if (assertion.required.length > 0 && !toolNames.has(SKILL_TOOL_NAME)) {
|
|
failures.push(`${SKILL_TOOL_NAME} tool was not advertised`);
|
|
}
|
|
if (assertion.required.length === 0 && toolNames.has(SKILL_TOOL_NAME)) {
|
|
failures.push(`${SKILL_TOOL_NAME} tool was unexpectedly advertised`);
|
|
}
|
|
for (const name of assertion.required) {
|
|
if (!promptHasSkillCatalogEntry(promptText, name)) {
|
|
failures.push(`${name} was not present in the model-visible catalog`);
|
|
}
|
|
}
|
|
for (const name of assertion.requiredBodies) {
|
|
const expectedMarker = expectedSkillBodyMarker(name);
|
|
const taggedBody = skillPrimeMessages.find((message) => message.name === name);
|
|
if (!taggedBody?.content.includes(expectedMarker)) {
|
|
failures.push(`${name} body was missing its expected marker "${expectedMarker}"`);
|
|
}
|
|
}
|
|
for (const name of assertion.forbidden) {
|
|
if (promptHasSkillCatalogEntry(promptText, name)) {
|
|
failures.push(`${name} leaked into the model-visible catalog`);
|
|
}
|
|
if (skillPrimeMessages.some((message) => message.name === name)) {
|
|
failures.push(`${name} was unexpectedly primed`);
|
|
}
|
|
if (name === MODEL_SPEC_ACCESSIBLE_SKILL && promptText.includes(ALWAYS_APPLY_BODY_MARKER)) {
|
|
failures.push(`${name} always-apply body marker leaked into the model prompt`);
|
|
}
|
|
if (name === DEPLOYMENT_SKILL_NAME && promptText.includes(DEPLOYMENT_SKILL_BODY_MARKER)) {
|
|
failures.push(`${name} always-apply body marker leaked into the model prompt`);
|
|
}
|
|
}
|
|
if (failures.length > 0) {
|
|
return {
|
|
responses: [`E2E skill assertion failed: ${failures.join('; ')}`],
|
|
};
|
|
}
|
|
return {
|
|
responses: [
|
|
`${SKILL_ASSERTION_FINAL_TEXT}: ${
|
|
assertion.required.length > 0 ? assertion.required.join(', ') : 'none'
|
|
}`,
|
|
],
|
|
};
|
|
}
|
|
|
|
function manualSkillAssertionResponses({ messages, skillName }) {
|
|
const taggedPrime = collectSkillPrimeMessages(messages).find(
|
|
(message) => message.name === skillName && message.trigger === 'manual',
|
|
);
|
|
if (!taggedPrime) {
|
|
return {
|
|
responses: [`E2E manual skill assertion failed: ${skillName} was not manually primed`],
|
|
};
|
|
}
|
|
if (!taggedPrime.content.includes(`# ${skillName}`)) {
|
|
return {
|
|
responses: [`E2E manual skill assertion failed: ${skillName} body was missing`],
|
|
};
|
|
}
|
|
return {
|
|
responses: [`${MANUAL_SKILL_ASSERTION_FINAL_TEXT}: ${skillName}`],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Exercises the real event-driven `skill` handler. The first model call emits
|
|
* the tool request; the second verifies both the visible tool result and the
|
|
* body-bearing meta HumanMessage that ToolNode reinjected for the model.
|
|
*/
|
|
function skillToolInvocationResponses({ skillName, toolNames }) {
|
|
if (!toolNames.has(SKILL_TOOL_NAME)) {
|
|
return {
|
|
responses: [`E2E skill tool assertion failed: ${SKILL_TOOL_NAME} was not advertised`],
|
|
};
|
|
}
|
|
|
|
return {
|
|
responses: ['', ''],
|
|
toolCalls: [
|
|
{
|
|
id: `call_e2e_skill_${skillName}`,
|
|
name: SKILL_TOOL_NAME,
|
|
args: { skillName },
|
|
type: 'tool_call',
|
|
},
|
|
],
|
|
resolveOnStream: (streamMessages) => {
|
|
const toolResult = findLastToolMessageText(
|
|
streamMessages,
|
|
`Skill "${skillName}" loaded. Follow the instructions below.`,
|
|
);
|
|
if (!toolResult) {
|
|
return null;
|
|
}
|
|
|
|
const expectedMarker = expectedSkillBodyMarker(skillName);
|
|
const modelInvokedPrime = collectSkillPrimeMessages(streamMessages).find(
|
|
(message) =>
|
|
message.name === skillName &&
|
|
message.trigger == null &&
|
|
message.content.includes(expectedMarker),
|
|
);
|
|
if (!modelInvokedPrime) {
|
|
return {
|
|
responses: [
|
|
`E2E skill tool assertion failed: ${skillName} body was not reinjected with marker "${expectedMarker}"`,
|
|
],
|
|
};
|
|
}
|
|
return {
|
|
responses: [`${SKILL_TOOL_ASSERTION_FINAL_TEXT}: ${skillName}`],
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildSkillBody(skillName) {
|
|
return `---
|
|
name: ${skillName}
|
|
description: ${SKILL_DESCRIPTION}
|
|
---
|
|
|
|
# ${skillName}
|
|
|
|
Created by the Playwright mock e2e suite to verify host file authoring without code execution.`;
|
|
}
|
|
|
|
function buildCreateSkillArgs(skillName) {
|
|
return {
|
|
path: `skills/${skillName}/SKILL.md`,
|
|
content: buildSkillBody(skillName),
|
|
overwrite: false,
|
|
};
|
|
}
|
|
|
|
function buildEditSkillArgs(skillName) {
|
|
return {
|
|
path: `skills/${skillName}/SKILL.md`,
|
|
old_text: `description: ${SKILL_DESCRIPTION}`,
|
|
new_text: `description: ${EDITED_SKILL_DESCRIPTION}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pick the fake-model script for a skill file-authoring turn. The graph runs two
|
|
* model turns: turn 1 streams the (empty) preamble and emits the tool call, the
|
|
* tool node writes the SKILL.md, then turn 2 streams the final text. The guards
|
|
* assert the feature advertised the host file-authoring tool and did NOT enable
|
|
* code execution.
|
|
*/
|
|
function fileAuthoringResponses(operation, toolNames) {
|
|
if (!toolNames.has(operation.toolName)) {
|
|
return {
|
|
responses: [`E2E file authoring unavailable: ${operation.toolName} was not advertised.`],
|
|
};
|
|
}
|
|
if (toolNames.has(BASH_TOOL_NAME)) {
|
|
return {
|
|
responses: [`E2E file authoring unavailable: ${BASH_TOOL_NAME} was unexpectedly advertised.`],
|
|
};
|
|
}
|
|
return {
|
|
responses: ['', `${operation.finalText}: ${operation.skillName}`],
|
|
toolCalls: [
|
|
{
|
|
id: operation.toolCallId,
|
|
name: operation.toolName,
|
|
args: operation.args,
|
|
type: 'tool_call',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Slow two-turn run with a real MCP tool boundary for the steering e2e: turn 1
|
|
* streams a slow preamble then calls the advertised `remember_fact` MCP tool
|
|
* (steers drain at the PostToolBatch boundary), turn 2 streams the final text.
|
|
*/
|
|
function steerToolReplyResponses(label, toolNames) {
|
|
const toolName = Array.from(toolNames).find((name) => name.startsWith(STEER_TOOL_NAME_PREFIX));
|
|
if (!toolName) {
|
|
return {
|
|
responses: [
|
|
`E2E steer tool reply unavailable: no ${STEER_TOOL_NAME_PREFIX} tool advertised.`,
|
|
],
|
|
};
|
|
}
|
|
const chunks = Array.from(
|
|
{ length: SLOW_REPLY_CHUNKS },
|
|
(_, index) => `chunk-${String(index).padStart(3, '0')}`,
|
|
).join(' ');
|
|
return {
|
|
responses: [`E2E steer tool preamble ${label} ${chunks}`, `${STEER_TOOL_FINAL_TEXT} ${label}`],
|
|
sleep: SLOW_CHUNK_DELAY_MS,
|
|
toolCalls: [
|
|
{
|
|
id: `call_e2e_steer_${label}`,
|
|
name: toolName,
|
|
args: { fact: `steer boundary ${label}` },
|
|
type: 'tool_call',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function findLastToolMessageText(messages, requiredToken) {
|
|
for (let index = (messages ?? []).length - 1; index >= 0; index--) {
|
|
const message = messages[index];
|
|
if (!message || messageType(message) !== 'tool') {
|
|
continue;
|
|
}
|
|
const content = getContentText(message.content);
|
|
if (content.includes(requiredToken)) {
|
|
return content;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function approvalToolResponses(label, toolNames, review) {
|
|
if (!toolNames.has(APPROVAL_TOOL_NAME)) {
|
|
return {
|
|
responses: [`E2E approval unavailable: ${APPROVAL_TOOL_NAME} was not advertised.`],
|
|
};
|
|
}
|
|
return {
|
|
responses: ['', ''],
|
|
toolCalls: [
|
|
{
|
|
id: `${APPROVAL_TOOL_CALL_PREFIX}${label}`,
|
|
name: APPROVAL_TOOL_NAME,
|
|
args: {
|
|
value: `original-${label}`,
|
|
...(review ? { review } : {}),
|
|
},
|
|
type: 'tool_call',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function batchApprovalToolResponses(label, toolNames) {
|
|
if (!toolNames.has(APPROVAL_TOOL_NAME)) {
|
|
return {
|
|
responses: [`E2E approval unavailable: ${APPROVAL_TOOL_NAME} was not advertised.`],
|
|
};
|
|
}
|
|
return {
|
|
responses: ['', ''],
|
|
toolCalls: [
|
|
{
|
|
id: `${APPROVAL_TOOL_CALL_PREFIX}${label}_first`,
|
|
name: APPROVAL_TOOL_NAME,
|
|
args: { value: `first-${label}` },
|
|
type: 'tool_call',
|
|
},
|
|
{
|
|
id: `${APPROVAL_TOOL_CALL_PREFIX}${label}_second`,
|
|
name: APPROVAL_TOOL_NAME,
|
|
args: { value: `second-${label}` },
|
|
type: 'tool_call',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resume rebuilds the fake model without the original prompt in `context.messages`.
|
|
* Detect the checkpoint-restored approval tool messages on every model instance
|
|
* so the continuation can report the real approve/reject/edit/respond outcome.
|
|
*/
|
|
function approvalOutcomeResponses(messages) {
|
|
let latestHumanIndex = -1;
|
|
for (let index = 0; index < (messages ?? []).length; index++) {
|
|
const type = messageType(messages[index]);
|
|
if (type === 'human' || type === 'user') {
|
|
latestHumanIndex = index;
|
|
}
|
|
}
|
|
|
|
const outcomeMessages = (messages ?? [])
|
|
.slice(latestHumanIndex + 1)
|
|
.filter(
|
|
(message) =>
|
|
messageType(message) === 'tool' &&
|
|
typeof message?.tool_call_id === 'string' &&
|
|
message.tool_call_id.startsWith(APPROVAL_TOOL_CALL_PREFIX),
|
|
);
|
|
|
|
const isBatch = outcomeMessages.some(
|
|
(message) =>
|
|
message.tool_call_id.endsWith('_first') || message.tool_call_id.endsWith('_second'),
|
|
);
|
|
if (isBatch && outcomeMessages.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
const outcomes = outcomeMessages.map((message) => getContentText(message.content));
|
|
|
|
if (outcomes.length === 0) {
|
|
return null;
|
|
}
|
|
return { responses: [`E2E approval outcomes: ${outcomes.join(' | ')}`] };
|
|
}
|
|
|
|
/**
|
|
* Turn 1 of the background e2e: emit the MCP tool call with the injected
|
|
* `run_in_background: true` arg, then (second model invocation, after the
|
|
* executor returned the synthetic handle) acknowledge the handle. Streaming
|
|
* `status=running` from the handle proves the dispatch returned before the
|
|
* tool finished — the non-blocking contract — without timing assertions.
|
|
*/
|
|
function backgroundDispatchResponses(name, toolNames) {
|
|
if (!toolNames.has(BACKGROUND_TOOL_NAME)) {
|
|
return {
|
|
responses: [`E2E background unavailable: ${BACKGROUND_TOOL_NAME} was not advertised.`],
|
|
};
|
|
}
|
|
if (!toolNames.has(CHECK_BACKGROUND_TASK_TOOL_NAME)) {
|
|
return {
|
|
responses: [
|
|
`E2E background unavailable: ${CHECK_BACKGROUND_TASK_TOOL_NAME} was not advertised.`,
|
|
],
|
|
};
|
|
}
|
|
return {
|
|
responses: ['', ''],
|
|
toolCalls: [
|
|
{
|
|
id: BACKGROUND_DISPATCH_TOOL_CALL_ID,
|
|
name: BACKGROUND_TOOL_NAME,
|
|
args: { text: `bg-${name}`, delay_ms: 1500, run_in_background: true },
|
|
type: 'tool_call',
|
|
},
|
|
],
|
|
resolveOnStream: (streamMessages) => {
|
|
const handleText = findLastToolMessageText(streamMessages, 'background_task_id');
|
|
if (!handleText) {
|
|
return null;
|
|
}
|
|
const taskId = handleText.match(/"background_task_id":"([^"]+)"/)?.[1] ?? 'missing';
|
|
const status = handleText.match(/"status":"(\w+)"/)?.[1] ?? 'missing';
|
|
return { responses: [`E2E background dispatched id=${taskId} status=${status}`] };
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Turn 2 of the background e2e: recover the task id from the replayed turn-1
|
|
* handle in history, poll `check_background_task` with it, and stream the
|
|
* collected status + echoed text — proving the detached result survived turn
|
|
* end and was retrieved cross-turn.
|
|
*/
|
|
function backgroundCollectResponses(messages, toolNames) {
|
|
if (!toolNames.has(CHECK_BACKGROUND_TASK_TOOL_NAME)) {
|
|
return {
|
|
responses: [
|
|
`E2E background unavailable: ${CHECK_BACKGROUND_TASK_TOOL_NAME} was not advertised.`,
|
|
],
|
|
};
|
|
}
|
|
const historyText = collectPromptText((messages ?? []).map((message) => message?.content)).join(
|
|
'\n',
|
|
);
|
|
const taskIds = [...historyText.matchAll(/"background_task_id":"([^"]+)"/g)].map(
|
|
(match) => match[1],
|
|
);
|
|
const taskId = taskIds[taskIds.length - 1];
|
|
if (!taskId) {
|
|
return {
|
|
responses: ['E2E background collect failed: no background_task_id found in history.'],
|
|
};
|
|
}
|
|
return {
|
|
responses: ['', ''],
|
|
toolCalls: [
|
|
{
|
|
id: BACKGROUND_COLLECT_TOOL_CALL_ID,
|
|
name: CHECK_BACKGROUND_TASK_TOOL_NAME,
|
|
args: { background_task_id: taskId },
|
|
type: 'tool_call',
|
|
},
|
|
],
|
|
resolveOnStream: (streamMessages) => {
|
|
/** Only the poll result (`serializeTask`) carries a `progress` key — the
|
|
* replayed dispatch handle in history does not. */
|
|
const pollText = findLastToolMessageText(streamMessages, '"progress"');
|
|
if (!pollText) {
|
|
return null;
|
|
}
|
|
const status = pollText.match(/"status":"(\w+)"/)?.[1] ?? 'missing';
|
|
const echo = pollText.match(/E2E slow echo: (bg-[\w-]+)/)?.[1] ?? 'missing';
|
|
return { responses: [`E2E background collected status=${status} echo=${echo}`] };
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseHandoffScript(text) {
|
|
const encodedScript = getMarkerValue(text, HANDOFF_MARKER);
|
|
if (!encodedScript) {
|
|
return null;
|
|
}
|
|
|
|
let value;
|
|
try {
|
|
value = JSON.parse(Buffer.from(encodedScript, 'base64url').toString('utf8'));
|
|
} catch (error) {
|
|
return {
|
|
error: `could not decode marker (${error instanceof Error ? error.message : 'unknown error'})`,
|
|
};
|
|
}
|
|
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return { error: 'script must be an object' };
|
|
}
|
|
if (typeof value.label !== 'string' || value.label.trim() === '') {
|
|
return { error: 'script.label must be a non-empty string' };
|
|
}
|
|
if (!Array.isArray(value.routes) || value.routes.length === 0) {
|
|
return { error: 'script.routes must be a non-empty array' };
|
|
}
|
|
|
|
const routes = [];
|
|
for (const [index, route] of value.routes.entries()) {
|
|
if (!route || typeof route !== 'object' || Array.isArray(route)) {
|
|
return { error: `script.routes[${index}] must be an object` };
|
|
}
|
|
if (typeof route.from !== 'string' || route.from === '') {
|
|
return { error: `script.routes[${index}].from must be a non-empty string` };
|
|
}
|
|
if (typeof route.to !== 'string' || route.to === '') {
|
|
return { error: `script.routes[${index}].to must be a non-empty string` };
|
|
}
|
|
if (route.args != null && (typeof route.args !== 'object' || Array.isArray(route.args))) {
|
|
return { error: `script.routes[${index}].args must be an object` };
|
|
}
|
|
if (route.description != null && typeof route.description !== 'string') {
|
|
return { error: `script.routes[${index}].description must be a string` };
|
|
}
|
|
if (route.prompt != null && typeof route.prompt !== 'string') {
|
|
return { error: `script.routes[${index}].prompt must be a string` };
|
|
}
|
|
if (route.promptKey != null && typeof route.promptKey !== 'string') {
|
|
return { error: `script.routes[${index}].promptKey must be a string` };
|
|
}
|
|
if (route.receipt != null && typeof route.receipt !== 'string') {
|
|
return { error: `script.routes[${index}].receipt must be a string` };
|
|
}
|
|
if (route.targetInstructions != null && typeof route.targetInstructions !== 'string') {
|
|
return { error: `script.routes[${index}].targetInstructions must be a string` };
|
|
}
|
|
if (
|
|
route.targetTools != null &&
|
|
(!Array.isArray(route.targetTools) ||
|
|
route.targetTools.some((toolName) => typeof toolName !== 'string' || toolName === ''))
|
|
) {
|
|
return {
|
|
error: `script.routes[${index}].targetTools must be an array of non-empty strings`,
|
|
};
|
|
}
|
|
|
|
const args = route.args ?? {};
|
|
let inferredReceipt = null;
|
|
if (typeof args.instructions === 'string') {
|
|
inferredReceipt = args.instructions;
|
|
} else if (typeof args.context === 'string') {
|
|
inferredReceipt = args.context;
|
|
}
|
|
routes.push({
|
|
from: route.from,
|
|
to: route.to,
|
|
description: route.description,
|
|
prompt: route.prompt,
|
|
promptKey: route.promptKey,
|
|
args,
|
|
receipt: route.receipt ?? inferredReceipt,
|
|
targetInstructions: route.targetInstructions,
|
|
targetTools: route.targetTools ?? [],
|
|
});
|
|
}
|
|
|
|
return {
|
|
script: {
|
|
label: value.label.trim(),
|
|
routes,
|
|
},
|
|
};
|
|
}
|
|
|
|
function getGraphTools(agentContext) {
|
|
const result = new Map();
|
|
const tools =
|
|
typeof agentContext?.getToolsForBinding === 'function'
|
|
? agentContext.getToolsForBinding()
|
|
: agentContext?.graphTools;
|
|
for (const tool of tools ?? []) {
|
|
if (typeof tool?.name === 'string') {
|
|
result.set(tool.name, tool);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function validateHandoffTool(route, tool, toolName) {
|
|
const failures = [];
|
|
const expectedDescription = route.description ?? `Transfer control to agent '${route.to}'`;
|
|
if (tool.description !== expectedDescription) {
|
|
failures.push(
|
|
`${toolName} description mismatch (expected "${expectedDescription}", received "${tool.description ?? ''}")`,
|
|
);
|
|
}
|
|
|
|
const schema = tool.schema;
|
|
const properties =
|
|
schema &&
|
|
typeof schema === 'object' &&
|
|
!Array.isArray(schema) &&
|
|
schema.properties &&
|
|
typeof schema.properties === 'object' &&
|
|
!Array.isArray(schema.properties)
|
|
? schema.properties
|
|
: null;
|
|
if (!properties) {
|
|
failures.push(`${toolName} did not expose an object properties schema`);
|
|
return failures;
|
|
}
|
|
|
|
const propertyNames = Object.keys(properties);
|
|
if (route.prompt == null) {
|
|
if (propertyNames.length > 0) {
|
|
failures.push(
|
|
`${toolName} unexpectedly advertised input properties: ${propertyNames.join(', ')}`,
|
|
);
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
const expectedPromptKey = route.promptKey ?? 'instructions';
|
|
const promptProperty = properties[expectedPromptKey];
|
|
if (!promptProperty || typeof promptProperty !== 'object' || Array.isArray(promptProperty)) {
|
|
failures.push(`${toolName} did not advertise the "${expectedPromptKey}" input property`);
|
|
return failures;
|
|
}
|
|
if (propertyNames.length !== 1) {
|
|
failures.push(
|
|
`${toolName} advertised unexpected input properties: ${propertyNames.join(', ')}`,
|
|
);
|
|
}
|
|
if (promptProperty.type !== 'string') {
|
|
failures.push(`${toolName}.${expectedPromptKey} was not a string input`);
|
|
}
|
|
if (promptProperty.description !== route.prompt) {
|
|
failures.push(
|
|
`${toolName}.${expectedPromptKey} description mismatch (expected "${route.prompt}", received "${promptProperty.description ?? ''}")`,
|
|
);
|
|
}
|
|
if (Array.isArray(schema.required) && schema.required.length > 0) {
|
|
failures.push(`${toolName} unexpectedly required optional handoff input`);
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
function validateHandoffScript(graph, script) {
|
|
const failures = [];
|
|
for (const route of script.routes) {
|
|
const agentContext = graph.agentContexts?.get(route.from);
|
|
if (!agentContext) {
|
|
failures.push(`source agent ${route.from} was not loaded`);
|
|
continue;
|
|
}
|
|
const toolName = `${HANDOFF_TOOL_PREFIX}${route.to}`;
|
|
const tool = getGraphTools(agentContext).get(toolName);
|
|
if (!tool) {
|
|
failures.push(`${toolName} was not advertised by source agent ${route.from}`);
|
|
continue;
|
|
}
|
|
failures.push(...validateHandoffTool(route, tool, toolName));
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
function getAgentIdFromInvocationOptions(options, runManager) {
|
|
const metadataCandidates = [
|
|
options?.metadata,
|
|
options?.configurable,
|
|
runManager?.metadata,
|
|
runManager?.inheritableMetadata,
|
|
];
|
|
for (const metadata of metadataCandidates) {
|
|
const node = metadata?.langgraph_node;
|
|
if (typeof node === 'string' && node.startsWith('agent=')) {
|
|
return node.slice('agent='.length);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function validateHandoffReception(graph, script, route, messages) {
|
|
const sourceContext = graph.agentContexts?.get(route.from);
|
|
const targetContext = graph.agentContexts?.get(route.to);
|
|
const sourceName = sourceContext?.name ?? route.from;
|
|
const targetName = targetContext?.name ?? route.to;
|
|
const promptMessages = targetContext?.systemRunnable
|
|
? await targetContext.systemRunnable.invoke(messages ?? [])
|
|
: (messages ?? []);
|
|
const promptText = promptMessages
|
|
.map((message) => getContentText(message?.content))
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
const failures = [];
|
|
|
|
const identityPreamble = `You are "${targetName}", transferred from "${sourceName}".`;
|
|
if (!promptText.includes(identityPreamble)) {
|
|
failures.push(`missing identity preamble: ${identityPreamble}`);
|
|
}
|
|
|
|
const siblingNames = Array.from(
|
|
new Set(
|
|
script.routes
|
|
.filter((candidate) => candidate !== route && candidate.from === route.from)
|
|
.map((candidate) => graph.agentContexts?.get(candidate.to)?.name ?? candidate.to),
|
|
),
|
|
);
|
|
const parallelPreamble = 'Running in parallel with:';
|
|
if (siblingNames.length === 0 && promptText.includes(parallelPreamble)) {
|
|
failures.push('unexpected parallel sibling preamble');
|
|
}
|
|
if (
|
|
siblingNames.length > 0 &&
|
|
!promptText.includes(`${parallelPreamble} ${siblingNames.join(', ')}.`)
|
|
) {
|
|
failures.push(`missing parallel sibling preamble for ${siblingNames.join(', ')}`);
|
|
}
|
|
|
|
if (route.targetInstructions && !promptText.includes(route.targetInstructions)) {
|
|
failures.push(`missing target instructions: ${route.targetInstructions}`);
|
|
}
|
|
|
|
const sourceTools = getGraphTools(sourceContext);
|
|
const targetTools = getGraphTools(targetContext);
|
|
for (const toolName of route.targetTools) {
|
|
if (!targetTools.has(toolName)) {
|
|
failures.push(`target agent ${route.to} did not receive its configured tool ${toolName}`);
|
|
}
|
|
if (route.from !== route.to && sourceTools.has(toolName)) {
|
|
failures.push(`target-only tool ${toolName} leaked to source agent ${route.from}`);
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
function buildHandoffResponses(graph, parsed) {
|
|
if (parsed.error) {
|
|
return {
|
|
responses: [`E2E handoff script invalid: ${parsed.error}`],
|
|
};
|
|
}
|
|
|
|
const { script } = parsed;
|
|
const failures = validateHandoffScript(graph, script);
|
|
if (failures.length > 0) {
|
|
return {
|
|
responses: [`E2E handoff unavailable: ${failures.join('; ')}`],
|
|
};
|
|
}
|
|
|
|
let invocationCount = 0;
|
|
return {
|
|
responses: [''],
|
|
resolveInvocation: async (messages, options, runManager) => {
|
|
const latestUserText = getLatestUserText(messages).trim();
|
|
const agentId = getAgentIdFromInvocationOptions(options, runManager);
|
|
let incomingRoute = script.routes.find(
|
|
(route) => route.receipt != null && latestUserText === route.receipt.trim(),
|
|
);
|
|
|
|
if (!agentId) {
|
|
return {
|
|
response: `E2E handoff routing failed ${script.label}: missing SDK langgraph_node metadata`,
|
|
};
|
|
}
|
|
invocationCount += 1;
|
|
|
|
const incomingRoutes = script.routes.filter((route) => route.to === agentId);
|
|
if (!incomingRoute && incomingRoutes.length === 1) {
|
|
incomingRoute = incomingRoutes[0];
|
|
}
|
|
if (incomingRoute?.receipt != null && latestUserText !== incomingRoute.receipt.trim()) {
|
|
return {
|
|
response:
|
|
`E2E handoff receipt failed ${script.label}: agent=${agentId}; ` +
|
|
`expected=${incomingRoute.receipt}; received=${latestUserText || '(empty)'}`,
|
|
};
|
|
}
|
|
if (incomingRoute) {
|
|
const receptionFailures = await validateHandoffReception(
|
|
graph,
|
|
script,
|
|
incomingRoute,
|
|
messages,
|
|
);
|
|
if (receptionFailures.length > 0) {
|
|
return {
|
|
response:
|
|
`E2E handoff reception failed ${script.label}: agent=${agentId}; ` +
|
|
receptionFailures.join('; '),
|
|
};
|
|
}
|
|
}
|
|
|
|
const outgoingRoutes = script.routes.filter((route) => route.from === agentId);
|
|
if (outgoingRoutes.length === 0) {
|
|
const received =
|
|
incomingRoute?.receipt == null ? '(no injected handoff content)' : latestUserText;
|
|
return {
|
|
response: `E2E handoff complete ${script.label}: agent=${agentId}; received=${received}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
response: `E2E handoff continuing ${script.label}: agent=${agentId}`,
|
|
toolCalls: outgoingRoutes.map((route, index) => ({
|
|
id: `call_e2e_handoff_${invocationCount}_${index}_${route.to}`,
|
|
name: `${HANDOFF_TOOL_PREFIX}${route.to}`,
|
|
args: route.args,
|
|
type: 'tool_call',
|
|
})),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
function resolveResponses({ graph, messages, text, toolNames }) {
|
|
const batchApprovalLabel = getMarkerValue(text, TOOL_APPROVAL_BATCH_MARKER);
|
|
if (batchApprovalLabel) {
|
|
return batchApprovalToolResponses(batchApprovalLabel, toolNames);
|
|
}
|
|
|
|
const restrictedApprovalLabel = getMarkerValue(text, TOOL_APPROVAL_RESTRICTED_MARKER);
|
|
if (restrictedApprovalLabel) {
|
|
return approvalToolResponses(restrictedApprovalLabel, toolNames, 'restricted');
|
|
}
|
|
|
|
const rewrittenApprovalLabel = getMarkerValue(text, TOOL_APPROVAL_REWRITE_MARKER);
|
|
if (rewrittenApprovalLabel) {
|
|
return approvalToolResponses(rewrittenApprovalLabel, toolNames, 'rewrite');
|
|
}
|
|
|
|
const approvalLabel = getMarkerValue(text, TOOL_APPROVAL_MARKER);
|
|
if (approvalLabel) {
|
|
return approvalToolResponses(approvalLabel, toolNames);
|
|
}
|
|
|
|
const reply = replyResponses(text);
|
|
if (reply) {
|
|
return reply;
|
|
}
|
|
|
|
const steerToolLabel = getMarkerValue(text, STEER_TOOL_REPLY_MARKER);
|
|
if (steerToolLabel) {
|
|
return steerToolReplyResponses(steerToolLabel, toolNames);
|
|
}
|
|
|
|
if (text.includes(ASSERT_AGENT_CONTEXT_MARKER)) {
|
|
return {
|
|
responses: [MOCK_REPLY],
|
|
resolveOnStream: (streamMessages) =>
|
|
agentContextAssertionResponses({ messages: streamMessages, text }),
|
|
};
|
|
}
|
|
|
|
const providerFileAssertion = providerFileAssertionResponses({ messages, text });
|
|
if (providerFileAssertion) {
|
|
return providerFileAssertion;
|
|
}
|
|
|
|
const quoteAssertion = quoteAssertionResponses({ messages, text });
|
|
if (quoteAssertion) {
|
|
return quoteAssertion;
|
|
}
|
|
|
|
if (text.includes(ASSERT_SKILLS_MARKER)) {
|
|
return {
|
|
responses: [MOCK_REPLY],
|
|
resolveOnStream: async (streamMessages, streamOptions, runManager) => {
|
|
const agentView = await getStreamAgentView({
|
|
graph,
|
|
messages: streamMessages,
|
|
options: streamOptions,
|
|
runManager,
|
|
});
|
|
return skillAssertionResponses({
|
|
messages: agentView.messages,
|
|
assertion: parseSkillAssertion(text, agentView.agentId),
|
|
toolNames: agentView.toolNames,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
if (text.includes(ASSERT_MANUAL_SKILL_MARKER)) {
|
|
const skillName = getMarkerValue(text, ASSERT_MANUAL_SKILL_MARKER);
|
|
return {
|
|
responses: [MOCK_REPLY],
|
|
resolveOnStream: async (streamMessages, streamOptions, runManager) => {
|
|
const agentView = await getStreamAgentView({
|
|
graph,
|
|
messages: streamMessages,
|
|
options: streamOptions,
|
|
runManager,
|
|
});
|
|
return manualSkillAssertionResponses({
|
|
messages: agentView.messages,
|
|
skillName,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
const invokedSkillName = getMarkerValue(text, INVOKE_SKILL_MARKER);
|
|
if (invokedSkillName) {
|
|
return skillToolInvocationResponses({
|
|
skillName: invokedSkillName,
|
|
toolNames,
|
|
});
|
|
}
|
|
|
|
const createSkillName = getRequestedSkillName(text, CREATE_SKILL_MARKER);
|
|
if (createSkillName) {
|
|
return fileAuthoringResponses(
|
|
{
|
|
skillName: createSkillName,
|
|
toolName: CREATE_FILE_TOOL_NAME,
|
|
toolCallId: CREATE_SKILL_TOOL_CALL_ID,
|
|
finalText: CREATE_FILE_AUTHORING_FINAL_TEXT,
|
|
args: buildCreateSkillArgs(createSkillName),
|
|
},
|
|
toolNames,
|
|
);
|
|
}
|
|
|
|
const backgroundDispatchName = getMarkerValue(text, BACKGROUND_DISPATCH_MARKER);
|
|
if (backgroundDispatchName) {
|
|
return backgroundDispatchResponses(backgroundDispatchName, toolNames);
|
|
}
|
|
|
|
if (text.includes(BACKGROUND_COLLECT_MARKER)) {
|
|
return backgroundCollectResponses(messages, toolNames);
|
|
}
|
|
|
|
const editSkillName = getRequestedSkillName(text, EDIT_SKILL_MARKER);
|
|
if (editSkillName) {
|
|
return fileAuthoringResponses(
|
|
{
|
|
skillName: editSkillName,
|
|
toolName: EDIT_FILE_TOOL_NAME,
|
|
toolCallId: EDIT_SKILL_TOOL_CALL_ID,
|
|
finalText: EDIT_FILE_AUTHORING_FINAL_TEXT,
|
|
args: buildEditSkillArgs(editSkillName),
|
|
},
|
|
toolNames,
|
|
);
|
|
}
|
|
|
|
return { responses: [MOCK_REPLY] };
|
|
}
|
|
|
|
/** @type {import('@hanzochat/api').TestRunHook} */
|
|
module.exports = function fakeModelHook(run, context) {
|
|
console.log('[e2e] HOOK FIRED graph=' + !!run?.Graph);
|
|
const graph = run?.Graph;
|
|
if (!graph || typeof graph.overrideTestModel !== 'function') {
|
|
console.warn('[e2e] fake-model hook: run.Graph.overrideTestModel unavailable');
|
|
return;
|
|
}
|
|
|
|
const text = getLatestUserText(context?.messages);
|
|
const toolNames = collectToolNames(context?.agents);
|
|
const handoffScript = parseHandoffScript(text);
|
|
const { responses, sleep, toolCalls, thrownError, resolveInvocation, resolveOnStream } =
|
|
handoffScript
|
|
? buildHandoffResponses(graph, handoffScript)
|
|
: resolveResponses({
|
|
graph,
|
|
messages: context?.messages,
|
|
text,
|
|
toolNames,
|
|
});
|
|
overrideModel({
|
|
graph,
|
|
responses,
|
|
sleep,
|
|
toolCalls,
|
|
thrownError,
|
|
resolveInvocation,
|
|
resolveOnStream: (streamMessages, streamOptions, runManager) =>
|
|
approvalOutcomeResponses(streamMessages) ??
|
|
resolveOnStream?.(streamMessages, streamOptions, runManager) ??
|
|
null,
|
|
});
|
|
};
|