Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
275f8b3
add sample e2e test for trueforge-core
ramantehlan Aug 25, 2026
b0eecef
Add orchestration e2e with sub-agents
ramantehlan Aug 26, 2026
f0a0848
add e2e test, expectation vs reality
ramantehlan Aug 31, 2026
a6338a7
Merge branch 'main' into sd/e2e-test
ramantehlan Sep 1, 2026
9144a39
e2e: add orchestration with approval
ramantehlan Sep 1, 2026
9b8835d
e2e: lint fix
ramantehlan Sep 1, 2026
5c66bbc
orchestration test: update name | add debug run | remove context matc…
ramantehlan Sep 1, 2026
906c5fc
Merge branch 'main' into sd/e2e-test
ramantehlan Sep 1, 2026
ee2791b
orchestration test: add note about the test
ramantehlan Sep 1, 2026
afef9bb
orchestration test: add note about the test
ramantehlan Sep 2, 2026
6ccde58
unify the orchestration test flow
ramantehlan Sep 2, 2026
29df9f3
test: remove vscode debug script
ramantehlan Sep 2, 2026
195aaa4
test: remove extra pnpm command for the tests
ramantehlan Sep 2, 2026
83d32e3
test: remove test readme
ramantehlan Sep 2, 2026
f92b1fa
Merge branch 'main' into sd/e2e-test
ramantehlan Sep 2, 2026
0ab9102
Merge branch 'main' into sd/e2e-test
ramantehlan Sep 2, 2026
4067a46
test: review comments
ramantehlan Sep 2, 2026
f091e63
test: simplify approval test
ramantehlan Sep 2, 2026
b3106a6
test: test context of event too in orchestration
ramantehlan Sep 2, 2026
99ad0e2
test: flaton the orchestration test
ramantehlan Sep 2, 2026
9086f0d
test: move tools from tool set to system capability
ramantehlan Sep 2, 2026
3287edc
Merge branch 'main' into sd/e2e-test
ramantehlan Sep 2, 2026
14dda84
test: improve normal test
ramantehlan Sep 2, 2026
16ede11
Merge branch 'main' into sd/e2e-test
ramantehlan Sep 3, 2026
a6a5d9c
Merge branch 'main' into sd/e2e-test
ramantehlan Sep 3, 2026
b9f05af
Merge branch 'main' into sd/e2e-test
ramantehlan Sep 3, 2026
5c5d887
test: add deny flow to the approval test
ramantehlan Sep 3, 2026
d83a845
Merge branch 'main' into sd/e2e-test
ramantehlan Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,5 @@ service-account*.json
# Local Cursor scratch (committed rules under .cursor/rules/ stay tracked)
.cursor/notes/
.cursor/plans/

.vscode
212 changes: 212 additions & 0 deletions packages/trueforge-core/tests/orchestration/helpers/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import type { ILLM } from '../../../src/core/llm/ILLM';
import type { ExtendedChatCompletionChunk, RawAssistantMessageWithUsage } from '../../../src/core/llm/LLMTypes';
import { getEmptyUsage } from '../../../src/core/llm/LLMTypes';
import type { IToolSet, ToolSource } from '../../../src/core/mcp/IMCPServer';
import { toolResultResponse } from '../../../src/core/mcp/IMCPServer';
import { ToolSet } from '../../../src/core/mcp/ToolSet';
import type {
AgentThreadExecutionEvent,
AgentThreadExecutionResult,
AgentThreadSendBatch,
} from '../../../src/core/runtime/AgentThread.types';
import type { AgentThreadOrchestrator } from '../../../src/core/runtime/AgentThreadOrchestrator';

export const WRITE_NOTE_TOOL_NAME = 'write_note';
export const WRITE_NOTE_CALL_ID = 'call-write';
export const WRITE_NOTE_ARGUMENTS = JSON.stringify({ text: 'hello' });
export const WRITE_NOTE_RESULT = 'note written';

/** One streamed chunk plus a stop completion. Used when the test needs a text reply and no tool calls. */
// eslint-disable-next-line @typescript-eslint/require-await -- async generator fixture, not awaiting I/O
export async function* textReplyStream(
text: string,
): AsyncGenerator<ExtendedChatCompletionChunk, RawAssistantMessageWithUsage, unknown> {
yield {
id: 'chunk-text',
object: 'chat.completion.chunk',
created: 0,
model: 'test-model',
choices: [{ index: 0, delta: { role: 'assistant', content: text }, finish_reason: 'stop' }],
};
return {
output: { role: 'assistant', content: text },
usage: getEmptyUsage(),
finish_reason: 'stop',
};
}

// eslint-disable-next-line @typescript-eslint/require-await -- async generator fixture, not awaiting I/O
export async function* createSubAgentStream() {
yield {
id: 'chunk-tool',
object: 'chat.completion.chunk',
created: 0,
model: 'test-model',
choices: [
{
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
id: 'call-sub',
type: 'function',
function: {
name: 'create_sub_agent',
arguments: JSON.stringify({ name: 'worker', input: 'do the delegated task' }),
},
},
],
},
finish_reason: 'tool_calls',
},
],
};

return {
output: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call-sub',
type: 'function',
function: {
name: 'create_sub_agent',
arguments: JSON.stringify({ name: 'worker', input: 'do the delegated task' }),
},
},
],
},
usage: getEmptyUsage(),
finish_reason: 'tool_calls',
};
}

// eslint-disable-next-line @typescript-eslint/require-await -- async generator fixture, not awaiting I/O
export async function* writeNoteToolCallStream() {
yield {
id: 'chunk-write-note',
object: 'chat.completion.chunk',
created: 0,
model: 'test-model',
choices: [
{
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
id: WRITE_NOTE_CALL_ID,
type: 'function',
function: {
name: WRITE_NOTE_TOOL_NAME,
arguments: WRITE_NOTE_ARGUMENTS,
},
},
],
},
finish_reason: 'tool_calls',
},
],
};

return {
output: {
role: 'assistant',
content: null,
tool_calls: [
{
id: WRITE_NOTE_CALL_ID,
type: 'function',
function: {
name: WRITE_NOTE_TOOL_NAME,
arguments: WRITE_NOTE_ARGUMENTS,
},
},
],
},
usage: getEmptyUsage(),
finish_reason: 'tool_calls',
};
}

function makeWriteNoteSource(callTool: ToolSource['callTool']): ToolSource {
return {
name: 'notes',
id: 'notes',
listTools: () =>
Promise.resolve({
result: {
tools: [
{
name: WRITE_NOTE_TOOL_NAME,
description: 'Write a note',
inputSchema: {
type: 'object',
properties: { text: { type: 'string' } },
},
preload: true,
},
],
},
wasInitialized: undefined,
}),
callTool,
toolCallInfo: () =>
Promise.resolve({
type: 'mcp',
mcp_server_id: 'notes',
mcp_server_name: 'notes',
original_tool_name: WRITE_NOTE_TOOL_NAME,
}),
};
}

/** Approval-gated write_note tool set; `callTool` spy proves allow runs the source and deny does not. */
export function makeApprovalGatedWriteNoteToolSet(): {
toolSet: IToolSet;
callTool: jest.Mock;
} {
const callTool = jest.fn(() => Promise.resolve(toolResultResponse({ text: WRITE_NOTE_RESULT })));
return {
toolSet: new ToolSet({
source: makeWriteNoteSource(callTool),
selectors: {
enableTools: ['@all'],
disableTools: [],
preloadTools: [],
requireApprovalForTools: [WRITE_NOTE_TOOL_NAME],
},
preload: true,
}),
callTool,
};
}

/** Consume send() then execute(); return raw events and the generator result. */
export async function runTurn(input: {
orchestrator: AgentThreadOrchestrator;
sendBatch: AgentThreadSendBatch;
signal?: AbortSignal | undefined;
}): Promise<{ events: AgentThreadExecutionEvent[]; result: AgentThreadExecutionResult }> {
for await (const _event of input.orchestrator.send(input.sendBatch)) {
void _event;
}
const events: AgentThreadExecutionEvent[] = [];
const iterator = input.orchestrator.execute({
signal: input.signal ?? new AbortController().signal,
});
let step = await iterator.next();
while (!step.done) {
events.push(step.value);
step = await iterator.next();
}
return { events, result: step.value };
}

export function llmCreateInputs(llm: ILLM): unknown[] {
return jest.mocked(llm).create.mock.calls.map(call => call[0]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/** One root thread, no tools: user message in, text reply out. */
import { EventType } from '../../src/core/events/schema';
import { AgentThread } from '../../src/core/runtime/AgentThread';
import { InternalEventType } from '../../src/core/runtime/AgentThread.types';
import { AgentThreadOrchestrator } from '../../src/core/runtime/AgentThreadOrchestrator';
import { NOOP_AGENT_TRACING } from '../../src/core/tracing/NoopAgentTracing';
import { makeSilentLogger } from '../core/harnessMocks';
import { llmCreateInputs, runTurn, textReplyStream } from './helpers/helpers';

const THREAD_ID = 'main';
const REPLY = 'hello from the mocked model';
const INSTRUCTION = 'You are running in a test setup.';

const EXPECTED_EVENTS = [
{ type: EventType.MODEL_MESSAGE, thread_id: THREAD_ID },
{ type: EventType.MODEL_MESSAGE_DELTA, thread_id: THREAD_ID, content: REPLY },
{
type: InternalEventType.AGENT_CONTEXT_APPEND,
thread_id: THREAD_ID,
context: [{ role: 'assistant', content: REPLY }],
},
{ type: InternalEventType.AGENT_DONE, thread_id: THREAD_ID, status: 'done' },
];

const OUTPUT = {
output: { thread_id: THREAD_ID, content: REPLY },
required_actions: [],
};

const EXPECTED_LLM_INPUT = [
{
stream: true,
messages: [
{ role: 'system', content: expect.stringContaining(INSTRUCTION) },
{ role: 'user', content: 'hello' },
],
},
];

describe('orchestration: mocked LLM and no tools', () => {
it('sends a user message and finishes the thread with a text reply', async () => {
const thread = new AgentThread({
definition: {
modelClient: {
create: jest.fn().mockImplementation(() => textReplyStream(REPLY)),
createNonStream: jest.fn().mockImplementation(() => textReplyStream(REPLY)),
},
instruction: INSTRUCTION,
messages: undefined,
modelParams: undefined,
responseFormat: undefined,
iterationLimit: undefined,
toolSets: undefined,
},
threadId: THREAD_ID,
title: 'orchestration',
parent: undefined,
agentInfo: undefined,
context: undefined,
currentContextUsage: undefined,
preComputedCompletion: undefined,
sandbox: undefined,
capabilities: undefined,
capabilityState: undefined,
tracing: NOOP_AGENT_TRACING,
logger: makeSilentLogger(),
});

// Orchestrator owns the thread map and fans send/execute across live threads.
// This case has only the root thread, so sub-agent creation must never run.
const orchestrator = new AgentThreadOrchestrator({
agentThreads: new Map([[thread.threadId, thread]]),
createDynamicSubAgentThread: () => Promise.reject(new Error('unexpected sub-agent in no-tool test')),
tracing: NOOP_AGENT_TRACING,
logger: makeSilentLogger(),
});

const { events, result } = await runTurn({
orchestrator,
sendBatch: [{ type: EventType.USER_MESSAGE, content: 'hello' }],
});

expect(events).toMatchObject(EXPECTED_EVENTS);
expect(result).toMatchObject(OUTPUT);
expect(result.root_agent_error).toBeUndefined();
expect(llmCreateInputs(thread.definition.modelClient)).toMatchObject(EXPECTED_LLM_INPUT);
});
});
Loading