From e6e74676f4c15489f9f360bbedcac75a0f2f8f7c Mon Sep 17 00:00:00 2001 From: "M. Emin Cihangeri" Date: Thu, 25 Jun 2026 15:51:58 +0200 Subject: [PATCH 01/49] fix: Auto-route tool call results to messages_history --- ...hestration-completion-post-request.test.ts | 58 ++++++++++++ .../orchestration/src/util/module-config.ts | 36 +++++--- sample-code/src/index.ts | 4 +- sample-code/src/orchestration.ts | 88 ++++++++++++++++++- tests/e2e-tests/src/orchestration.test.ts | 17 +++- 5 files changed, 189 insertions(+), 14 deletions(-) diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index 3019ad68a..3c63f2fbb 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -337,4 +337,62 @@ describe('construct completion post request', () => { ); expect(completionPostRequest).toEqual(expectedCompletionPostRequest); }); + + describe('tool message auto-routing', () => { + const toolCallId = 'call_abc123'; + const assistantMessage = { + role: 'assistant' as const, + tool_calls: [ + { + id: toolCallId, + type: 'function' as const, + function: { name: 'search', arguments: '{"query":"test"}' } + } + ] + }; + const toolMessage = { + role: 'tool' as const, + content: 'Result: {{?question}}', + tool_call_id: toolCallId + }; + const userMessage = { role: 'user' as const, content: 'Summarize.' }; + + it('should route tool messages from messages to messages_history', () => { + const result = constructCompletionPostRequest(defaultConfig, { + messages: [userMessage, toolMessage] + }); + + expect(result.messages_history).toEqual([userMessage, toolMessage]); + expect( + (result.config.modules as any).prompt_templating.prompt.template + ).not.toContainEqual(toolMessage); + expect( + (result.config.modules as any).prompt_templating.prompt.template + ).not.toContainEqual(userMessage); + }); + + it('should preserve existing messagesHistory when appending tool messages', () => { + const result = constructCompletionPostRequest(defaultConfig, { + messages: [userMessage, toolMessage], + messagesHistory: [assistantMessage] + }); + + expect(result.messages_history).toEqual([ + assistantMessage, + userMessage, + toolMessage + ]); + }); + + it('should not affect non-tool messages', () => { + const result = constructCompletionPostRequest(defaultConfig, { + messages: [userMessage] + }); + + expect(result.messages_history).toBeUndefined(); + expect( + (result.config.modules as any).prompt_templating.prompt.template + ).toContainEqual(userMessage); + }); + }); }); diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 75e508241..3047258e8 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -358,16 +358,31 @@ export function constructCompletionPostRequest( // - Single config (OrchestrationModuleConfig) → single ModuleConfigs object // - Config array (OrchestrationModuleConfigList) → array of ModuleConfigs for fallback behavior - // When any config uses a TemplateRef, messages cannot be merged into prompt.template - // (the template lives remotely). Route them to messages_history instead. + // Determine where to split request.messages: everything before splitIndex is routed + // to messages_history (bypassing prompt templating); everything from splitIndex onward + // stays in messages and is merged into prompt.template. + // + // Two cases route messages out of templating: + // 1. TemplateRef configs — the template lives remotely, so messages cannot be merged in. + // 2. Tool results — their content comes from external systems and may contain {{?...}} + // patterns that are not user-defined placeholders. We route through the last tool + // message so the assistant+tool pair stays together for tool_call_id validation. const configs = Array.isArray(config) ? config : [config]; - const routeMessagesToHistory = configs.some(c => + const usesTemplateRef = configs.some(c => isTemplateRef(c?.promptTemplating?.prompt || {}) ); + const messages = request?.messages || []; + const lastToolIndex = messages.reduce( + (last, msg, i) => (msg.role === 'tool' ? i : last), + -1 + ); + const splitIndex = usesTemplateRef + ? messages.length + : lastToolIndex + 1; const moduleRequest = - routeMessagesToHistory && request - ? { ...request, messages: undefined } + splitIndex > 0 && request + ? { ...request, messages: messages.slice(splitIndex) } : request; /** @@ -389,18 +404,17 @@ export function constructCompletionPostRequest( ) : { modules: moduleConfigurations }; - // When routing messages to history, append request.messages after messagesHistory - const messagesHistory = - routeMessagesToHistory && request?.messages?.length - ? [...(request.messagesHistory || []), ...request.messages] - : request?.messagesHistory; + const messagesHistory = [ + ...(request?.messagesHistory || []), + ...messages.slice(0, splitIndex) + ]; return { config: configWithStream, ...(request?.placeholderValues && { placeholder_values: request.placeholderValues }), - ...(messagesHistory && { + ...(messagesHistory.length && { messages_history: messagesHistory }) }; diff --git a/sample-code/src/index.ts b/sample-code/src/index.ts index 072dc1d39..57c1bb165 100644 --- a/sample-code/src/index.ts +++ b/sample-code/src/index.ts @@ -35,7 +35,9 @@ export { orchestrationWithFallbackConfigs, orchestrationSonarWithCitations, orchestrationSonarStreamWithCitations, - orchestrationStreamWithFallbackConfigs + orchestrationStreamWithFallbackConfigs, + orchestrationToolResultInMessages, + orchestrationToolResultMaskingInMessagesHistory } from './orchestration.js'; export { invoke, diff --git a/sample-code/src/orchestration.ts b/sample-code/src/orchestration.ts index 4849f7ce8..991f1f779 100644 --- a/sample-code/src/orchestration.ts +++ b/sample-code/src/orchestration.ts @@ -22,7 +22,8 @@ import type { OrchestrationErrorResponse, ChatCompletionTool, ToolChatMessage, - PromptTemplatingModule + PromptTemplatingModule, + AssistantChatMessage } from '@sap-ai-sdk/orchestration'; const logger = createLogger({ @@ -777,6 +778,91 @@ export async function orchestrationMessageHistoryWithToolCalling(): Promise { + const client = new OrchestrationClient({ + promptTemplating: { + model: { name: 'anthropic--claude-4.5-haiku' }, + prompt: { template: [{ role: 'system', content: 'You are helpful.' }] } + } + }); + + const assistantMessage: AssistantChatMessage = { + role: 'assistant', + tool_calls: [ + { + id: 'call_abc123', + type: 'function', + function: { name: 'search', arguments: '{"query":"test"}' } + } + ] + }; + + const toolMessage: ToolChatMessage = { + role: 'tool', + // Simulates a tool result from an external system that happens to contain + // prompt templating syntax — the SDK must route this to messages_history. + content: 'Search result: the user asked {{?question}}', + tool_call_id: 'call_abc123' + }; + + return client.chatCompletion({ + messages: [ + assistantMessage, + toolMessage, + { role: 'user', content: 'Summarize the tool output.' } + ] + }); +} + +/** + * Verify whether masking is applied to tool results automatically routed to messages_history. + * This confirms that auto-routing does not bypass anonymisation. + */ +export async function orchestrationToolResultMaskingInMessagesHistory(): Promise { + const client = new OrchestrationClient({ + promptTemplating: { + model: { name: 'anthropic--claude-4.5-haiku' } + }, + masking: { + masking_providers: [ + buildDpiMaskingProvider({ + method: 'pseudonymization', + entities: ['profile-email'] + }) + ] + } + }); + + const assistantMessage: AssistantChatMessage = { + role: 'assistant', + tool_calls: [ + { + id: 'call_abc123', + type: 'function', + function: { name: 'fetch_customer', arguments: '{"id":"42"}' } + } + ] + }; + + const toolMessage: ToolChatMessage = { + role: 'tool', + content: 'Customer email is john.doe@example.com', + tool_call_id: 'call_abc123' + }; + + return client.chatCompletion({ + messages: [ + assistantMessage, + toolMessage, + { role: 'user', content: 'Summarize the customer data.' } + ] + }); +} + /** * Use translation module for input and output translation with advanced features. * @returns The orchestration service response. diff --git a/tests/e2e-tests/src/orchestration.test.ts b/tests/e2e-tests/src/orchestration.test.ts index 42e29380e..0cb6ba2b3 100644 --- a/tests/e2e-tests/src/orchestration.test.ts +++ b/tests/e2e-tests/src/orchestration.test.ts @@ -24,7 +24,9 @@ import { orchestrationWithFallbackConfigs, orchestrationSonarWithCitations, orchestrationSonarStreamWithCitations, - orchestrationStreamWithFallbackConfigs + orchestrationStreamWithFallbackConfigs, + orchestrationToolResultInMessages, + orchestrationToolResultMaskingInMessagesHistory } from '@sap-ai-sdk/sample-code'; import { OrchestrationClient, @@ -332,4 +334,17 @@ describe('orchestration', () => { expect(Array.isArray(citations)).toBe(true); } }); + + describe('tool result prompt templating interference', () => { + it('should succeed when tool result containing {{?...}} syntax is sent via messages', async () => { + const response = await orchestrationToolResultInMessages(); + expect(response.getContent()).toEqual(expect.any(String)); + }); + + it('should apply masking to tool results automatically routed to messages_history', async () => { + const response = await orchestrationToolResultMaskingInMessagesHistory(); + expect(response.getIntermediateResults().input_masking).toBeDefined(); + expect(response.getContent()).toEqual(expect.any(String)); + }); + }); }); From 02e73cb337e0312ef0540ce968b6f31e91e92346 Mon Sep 17 00:00:00 2001 From: "M. Emin Cihangeri" Date: Thu, 25 Jun 2026 16:00:10 +0200 Subject: [PATCH 02/49] chore: Fix lint --- packages/orchestration/src/util/module-config.ts | 4 +--- sample-code/src/orchestration.ts | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 3047258e8..b96593d83 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -376,9 +376,7 @@ export function constructCompletionPostRequest( (last, msg, i) => (msg.role === 'tool' ? i : last), -1 ); - const splitIndex = usesTemplateRef - ? messages.length - : lastToolIndex + 1; + const splitIndex = usesTemplateRef ? messages.length : lastToolIndex + 1; const moduleRequest = splitIndex > 0 && request diff --git a/sample-code/src/orchestration.ts b/sample-code/src/orchestration.ts index 991f1f779..bc60f7d37 100644 --- a/sample-code/src/orchestration.ts +++ b/sample-code/src/orchestration.ts @@ -781,6 +781,7 @@ export async function orchestrationMessageHistoryWithToolCalling(): Promise { const client = new OrchestrationClient({ @@ -821,6 +822,7 @@ export async function orchestrationToolResultInMessages(): Promise { const client = new OrchestrationClient({ From 0170e97c694b349328dea806c5a137dd5768f2fc Mon Sep 17 00:00:00 2001 From: emincihangeri <76652821+emincihangeri@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:10:52 +0200 Subject: [PATCH 03/49] Update packages/orchestration/src/util/module-config.ts Co-authored-by: David Knaack --- packages/orchestration/src/util/module-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index b96593d83..7cf2ec115 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -412,7 +412,7 @@ export function constructCompletionPostRequest( ...(request?.placeholderValues && { placeholder_values: request.placeholderValues }), - ...(messagesHistory.length && { + ...((request?.messagesHistory || messagesHistory.length) && { messages_history: messagesHistory }) }; From 977206118031ddc6c71253a42f6c05e19fd10a62 Mon Sep 17 00:00:00 2001 From: "M. Emin Cihangeri" Date: Mon, 29 Jun 2026 14:23:31 +0200 Subject: [PATCH 04/49] chore: Add changeset --- .changeset/yummy-ducks-run.md | 5 +++++ .../src/orchestration-completion-post-request.test.ts | 10 +++++----- 2 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 .changeset/yummy-ducks-run.md diff --git a/.changeset/yummy-ducks-run.md b/.changeset/yummy-ducks-run.md new file mode 100644 index 000000000..c7df2f6ac --- /dev/null +++ b/.changeset/yummy-ducks-run.md @@ -0,0 +1,5 @@ +--- +'@sap-ai-sdk/orchestration': patch +--- + +[Fix] Route `role: "tool"` messages from `messages` to `messages_history` automatically. diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index 3c63f2fbb..b2238bac9 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -358,16 +358,16 @@ describe('construct completion post request', () => { const userMessage = { role: 'user' as const, content: 'Summarize.' }; it('should route tool messages from messages to messages_history', () => { - const result = constructCompletionPostRequest(defaultConfig, { + const result: any = constructCompletionPostRequest(defaultConfig, { messages: [userMessage, toolMessage] }); expect(result.messages_history).toEqual([userMessage, toolMessage]); expect( - (result.config.modules as any).prompt_templating.prompt.template + result.config.modules.prompt_templating.prompt.template ).not.toContainEqual(toolMessage); expect( - (result.config.modules as any).prompt_templating.prompt.template + result.config.modules.prompt_templating.prompt.template ).not.toContainEqual(userMessage); }); @@ -385,13 +385,13 @@ describe('construct completion post request', () => { }); it('should not affect non-tool messages', () => { - const result = constructCompletionPostRequest(defaultConfig, { + const result: any = constructCompletionPostRequest(defaultConfig, { messages: [userMessage] }); expect(result.messages_history).toBeUndefined(); expect( - (result.config.modules as any).prompt_templating.prompt.template + result.config.modules.prompt_templating.prompt.template ).toContainEqual(userMessage); }); }); From ba033a713850a5bd9e83a14ba0548e7038c9c996 Mon Sep 17 00:00:00 2001 From: "M. Emin Cihangeri" Date: Mon, 29 Jun 2026 14:36:27 +0200 Subject: [PATCH 05/49] chore: Enable ES2023 array methods in tsconfig --- packages/orchestration/src/util/module-config.ts | 5 +---- tsconfig.json | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 7cf2ec115..da3f3a4ad 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -372,10 +372,7 @@ export function constructCompletionPostRequest( isTemplateRef(c?.promptTemplating?.prompt || {}) ); const messages = request?.messages || []; - const lastToolIndex = messages.reduce( - (last, msg, i) => (msg.role === 'tool' ? i : last), - -1 - ); + const lastToolIndex = messages.findLastIndex(msg => msg.role === 'tool'); const splitIndex = usesTemplateRef ? messages.length : lastToolIndex + 1; const moduleRequest = diff --git a/tsconfig.json b/tsconfig.json index 42dfb4a53..44fc7a776 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "ES2022", + "lib": ["es2023", "dom"], "module": "Node16", "declaration": true, "declarationMap": true, From b733f83320a6f56757fc97dd3105ece0966e216e Mon Sep 17 00:00:00 2001 From: emincihangeri <76652821+emincihangeri@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:57:42 +0200 Subject: [PATCH 06/49] Update tsconfig.json Co-authored-by: David Knaack --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 44fc7a776..e472afeeb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["es2023", "dom"], + "lib": ["es2023"], "module": "Node16", "declaration": true, "declarationMap": true, From 06831ae8ca9e7b36abe2bc947f482173e14135ce Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Tue, 30 Jun 2026 16:22:40 +0200 Subject: [PATCH 07/49] fix: address review comments and add message order tests --- ...hestration-completion-post-request.test.ts | 30 +++++++++++++++++++ .../orchestration/src/orchestration-types.ts | 5 ++++ .../orchestration/src/util/module-config.ts | 22 ++++++++------ .../src/tutorials/mcp/weather-mcp-server.ts | 4 ++- tests/e2e-tests/src/orchestration.test.ts | 14 +++++++++ 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index b2238bac9..0c79cf360 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -394,5 +394,35 @@ describe('construct completion post request', () => { result.config.modules.prompt_templating.prompt.template ).toContainEqual(userMessage); }); + + it('should preserve chronological message order across the split', () => { + const followUpUser = { role: 'user' as const, content: 'Follow up.' }; + const result: any = constructCompletionPostRequest(defaultConfig, { + messages: [userMessage, assistantMessage, toolMessage, followUpUser] + }); + + // messages before and including last tool go to messages_history in order + expect(result.messages_history).toEqual([ + userMessage, + assistantMessage, + toolMessage + ]); + // only messages after the last tool stay in prompt.template + expect( + result.config.modules.prompt_templating.prompt.template + ).toContainEqual(followUpUser); + expect( + result.config.modules.prompt_templating.prompt.template + ).not.toContainEqual(toolMessage); + }); + + it('should emit messages_history when only messagesHistory is provided (no tool messages)', () => { + const result = constructCompletionPostRequest(defaultConfig, { + messages: [userMessage], + messagesHistory: [assistantMessage] + }); + + expect(result.messages_history).toEqual([assistantMessage]); + }); }); }); diff --git a/packages/orchestration/src/orchestration-types.ts b/packages/orchestration/src/orchestration-types.ts index 4a3b73007..f9fa55d62 100644 --- a/packages/orchestration/src/orchestration-types.ts +++ b/packages/orchestration/src/orchestration-types.ts @@ -43,6 +43,11 @@ export interface ChatCompletionRequest { /** * New chat messages, including template messages. + * Messages with `role: 'tool'` — and all messages preceding them — are automatically + * routed to `messages_history` to bypass prompt templating. This prevents tool results + * from external systems containing `{{?...}}` syntax from being misinterpreted as + * template placeholders. To verify the final message order sent to the LLM, use + * `response.getIntermediateResults().templating`. * @example * messages: [ * { diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index da3f3a4ad..5cbf37508 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -375,10 +375,14 @@ export function constructCompletionPostRequest( const lastToolIndex = messages.findLastIndex(msg => msg.role === 'tool'); const splitIndex = usesTemplateRef ? messages.length : lastToolIndex + 1; - const moduleRequest = - splitIndex > 0 && request - ? { ...request, messages: messages.slice(splitIndex) } - : request; + const remainingMessages = messages.slice(splitIndex); + let moduleRequest = request; + if (splitIndex > 0 && request) { + moduleRequest = { + ...request, + messages: remainingMessages.length ? remainingMessages : undefined + }; + } /** * Module configurations for the orchestration request. @@ -399,17 +403,17 @@ export function constructCompletionPostRequest( ) : { modules: moduleConfigurations }; - const messagesHistory = [ - ...(request?.messagesHistory || []), - ...messages.slice(0, splitIndex) - ]; + const messagesHistory = + splitIndex > 0 || request?.messagesHistory?.length + ? [...(request?.messagesHistory || []), ...messages.slice(0, splitIndex)] + : undefined; return { config: configWithStream, ...(request?.placeholderValues && { placeholder_values: request.placeholderValues }), - ...((request?.messagesHistory || messagesHistory.length) && { + ...(messagesHistory && { messages_history: messagesHistory }) }; diff --git a/sample-code/src/tutorials/mcp/weather-mcp-server.ts b/sample-code/src/tutorials/mcp/weather-mcp-server.ts index 632afd91d..7449ca064 100644 --- a/sample-code/src/tutorials/mcp/weather-mcp-server.ts +++ b/sample-code/src/tutorials/mcp/weather-mcp-server.ts @@ -48,7 +48,9 @@ server.registerTool( try { const geoUrl = buildGeocodingUrl(city); const geoResponse = await fetch(geoUrl); - const data = await geoResponse.json(); + const data = (await geoResponse.json()) as { + results?: { latitude: number; longitude: number }[]; + }; if (!data.results?.length) { return { diff --git a/tests/e2e-tests/src/orchestration.test.ts b/tests/e2e-tests/src/orchestration.test.ts index 0cb6ba2b3..96c8468ac 100644 --- a/tests/e2e-tests/src/orchestration.test.ts +++ b/tests/e2e-tests/src/orchestration.test.ts @@ -339,12 +339,26 @@ describe('orchestration', () => { it('should succeed when tool result containing {{?...}} syntax is sent via messages', async () => { const response = await orchestrationToolResultInMessages(); expect(response.getContent()).toEqual(expect.any(String)); + + const templating = response.getIntermediateResults().templating; + expect(templating).toBeDefined(); + const roles = templating!.map(m => m.role); + const toolIdx = roles.lastIndexOf('tool'); + const userIdx = roles.lastIndexOf('user'); + expect(toolIdx).toBeGreaterThan(-1); + expect(userIdx).toBeGreaterThan(toolIdx); }); it('should apply masking to tool results automatically routed to messages_history', async () => { const response = await orchestrationToolResultMaskingInMessagesHistory(); expect(response.getIntermediateResults().input_masking).toBeDefined(); expect(response.getContent()).toEqual(expect.any(String)); + + const templating = response.getIntermediateResults().templating; + expect(templating).toBeDefined(); + const roles = templating!.map(m => m.role); + expect(roles).toContain('tool'); + expect(roles.lastIndexOf('user')).toBeGreaterThan(roles.lastIndexOf('tool')); }); }); }); From 889db2c4df6c4e27a2535e759245df36995675eb Mon Sep 17 00:00:00 2001 From: "sap-ai-sdk-bot[bot]" <272306433+sap-ai-sdk-bot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:24:25 +0000 Subject: [PATCH 08/49] fix: Changes from lint --- tests/e2e-tests/src/orchestration.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e-tests/src/orchestration.test.ts b/tests/e2e-tests/src/orchestration.test.ts index 96c8468ac..84f3f8b7c 100644 --- a/tests/e2e-tests/src/orchestration.test.ts +++ b/tests/e2e-tests/src/orchestration.test.ts @@ -358,7 +358,9 @@ describe('orchestration', () => { expect(templating).toBeDefined(); const roles = templating!.map(m => m.role); expect(roles).toContain('tool'); - expect(roles.lastIndexOf('user')).toBeGreaterThan(roles.lastIndexOf('tool')); + expect(roles.lastIndexOf('user')).toBeGreaterThan( + roles.lastIndexOf('tool') + ); }); }); }); From 00deb0ce9aad827c9a55cf43b8a6ade32f29ac9c Mon Sep 17 00:00:00 2001 From: Injun Park Date: Fri, 3 Jul 2026 14:35:07 +0200 Subject: [PATCH 09/49] Apply suggestions from code review Co-authored-by: David Knaack --- .changeset/yummy-ducks-run.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/yummy-ducks-run.md b/.changeset/yummy-ducks-run.md index c7df2f6ac..19f0c1007 100644 --- a/.changeset/yummy-ducks-run.md +++ b/.changeset/yummy-ducks-run.md @@ -2,4 +2,4 @@ '@sap-ai-sdk/orchestration': patch --- -[Fix] Route `role: "tool"` messages from `messages` to `messages_history` automatically. +[Fix] Route messages preceding tool messages from the `promptTemplating` section `messages` property to `messages_history` automatically if no templating placeholder values are provided. From 7c91e686446e0a633882af4840b7173f5208896f Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Fri, 3 Jul 2026 14:41:58 +0200 Subject: [PATCH 10/49] apply feedbacks from david --- tests/e2e-tests/src/orchestration.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/e2e-tests/src/orchestration.test.ts b/tests/e2e-tests/src/orchestration.test.ts index 84f3f8b7c..e569fa88e 100644 --- a/tests/e2e-tests/src/orchestration.test.ts +++ b/tests/e2e-tests/src/orchestration.test.ts @@ -341,12 +341,8 @@ describe('orchestration', () => { expect(response.getContent()).toEqual(expect.any(String)); const templating = response.getIntermediateResults().templating; - expect(templating).toBeDefined(); const roles = templating!.map(m => m.role); - const toolIdx = roles.lastIndexOf('tool'); - const userIdx = roles.lastIndexOf('user'); - expect(toolIdx).toBeGreaterThan(-1); - expect(userIdx).toBeGreaterThan(toolIdx); + expect(roles).toEqual(['assistant', 'tool', 'user']); }); it('should apply masking to tool results automatically routed to messages_history', async () => { From 1d872147afd1e3ba89e472aefa2224749bfb9656 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Mon, 6 Jul 2026 13:48:32 +0200 Subject: [PATCH 11/49] fix: disable auto-routing when static prompt template is present - Skip tool-message auto-routing to messages_history when any config has a non-empty prompt.template (static template = user opted into templating, service handles it correctly without interference) - Move lib: [es2023] from root tsconfig.json to packages/orchestration/tsconfig.json to resolve merge conflict with main's tsconfig restructuring - Update unit tests to use a no-template config for auto-routing cases and add a test asserting auto-routing is disabled with static templates - Remove static system prompt from orchestrationToolResultInMessages sample so auto-routing is actually exercised in e2e --- ...hestration-completion-post-request.test.ts | 38 +++++++++++++++---- .../orchestration/src/util/module-config.ts | 10 ++++- packages/orchestration/tsconfig.json | 1 + sample-code/src/orchestration.ts | 3 +- 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index 0c79cf360..2acaa8d2e 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -357,12 +357,21 @@ describe('construct completion post request', () => { }; const userMessage = { role: 'user' as const, content: 'Summarize.' }; + // Config without a static template — auto-routing is active + const noTemplateConfig: OrchestrationModuleConfig = { + promptTemplating: { + model: { name: 'gpt-5.4-nano' }, + prompt: { template: [] } + } + }; + it('should route tool messages from messages to messages_history', () => { - const result: any = constructCompletionPostRequest(defaultConfig, { - messages: [userMessage, toolMessage] + const result: any = constructCompletionPostRequest(noTemplateConfig, { + messages: [userMessage, toolMessage, { role: 'user' as const, content: 'Follow up.' }] }); - expect(result.messages_history).toEqual([userMessage, toolMessage]); + expect(result.messages_history).toContainEqual(userMessage); + expect(result.messages_history).toContainEqual(toolMessage); expect( result.config.modules.prompt_templating.prompt.template ).not.toContainEqual(toolMessage); @@ -372,8 +381,9 @@ describe('construct completion post request', () => { }); it('should preserve existing messagesHistory when appending tool messages', () => { - const result = constructCompletionPostRequest(defaultConfig, { - messages: [userMessage, toolMessage], + const followUpUser = { role: 'user' as const, content: 'Follow up.' }; + const result = constructCompletionPostRequest(noTemplateConfig, { + messages: [userMessage, toolMessage, followUpUser], messagesHistory: [assistantMessage] }); @@ -385,7 +395,7 @@ describe('construct completion post request', () => { }); it('should not affect non-tool messages', () => { - const result: any = constructCompletionPostRequest(defaultConfig, { + const result: any = constructCompletionPostRequest(noTemplateConfig, { messages: [userMessage] }); @@ -397,7 +407,7 @@ describe('construct completion post request', () => { it('should preserve chronological message order across the split', () => { const followUpUser = { role: 'user' as const, content: 'Follow up.' }; - const result: any = constructCompletionPostRequest(defaultConfig, { + const result: any = constructCompletionPostRequest(noTemplateConfig, { messages: [userMessage, assistantMessage, toolMessage, followUpUser] }); @@ -417,12 +427,24 @@ describe('construct completion post request', () => { }); it('should emit messages_history when only messagesHistory is provided (no tool messages)', () => { - const result = constructCompletionPostRequest(defaultConfig, { + const result = constructCompletionPostRequest(noTemplateConfig, { messages: [userMessage], messagesHistory: [assistantMessage] }); expect(result.messages_history).toEqual([assistantMessage]); }); + + it('should not auto-route when config has a static prompt template', () => { + // static template present → auto-routing disabled, tool message stays in prompt.template + const result: any = constructCompletionPostRequest(defaultConfig, { + messages: [assistantMessage, toolMessage, userMessage] + }); + + expect(result.messages_history).toBeUndefined(); + expect( + result.config.modules.prompt_templating.prompt.template + ).toContainEqual(toolMessage); + }); }); }); diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 5cbf37508..f86b1d65c 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -367,12 +367,20 @@ export function constructCompletionPostRequest( // 2. Tool results — their content comes from external systems and may contain {{?...}} // patterns that are not user-defined placeholders. We route through the last tool // message so the assistant+tool pair stays together for tool_call_id validation. + // Auto-routing is skipped when any config has a non-empty prompt.template, because + // in that case the user has opted into templating and the service handles it correctly. const configs = Array.isArray(config) ? config : [config]; const usesTemplateRef = configs.some(c => isTemplateRef(c?.promptTemplating?.prompt || {}) ); + const hasStaticTemplate = configs.some(c => { + const prompt = c?.promptTemplating?.prompt; + return isTemplate(prompt) && prompt.template?.length; + }); const messages = request?.messages || []; - const lastToolIndex = messages.findLastIndex(msg => msg.role === 'tool'); + const lastToolIndex = hasStaticTemplate + ? -1 + : messages.findLastIndex(msg => msg.role === 'tool'); const splitIndex = usesTemplateRef ? messages.length : lastToolIndex + 1; const remainingMessages = messages.slice(splitIndex); diff --git a/packages/orchestration/tsconfig.json b/packages/orchestration/tsconfig.json index 2caf7a3cc..8cd9f136e 100644 --- a/packages/orchestration/tsconfig.json +++ b/packages/orchestration/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "lib": ["es2023"], "rootDir": "./src", "outDir": "./dist", "tsBuildInfoFile": "./dist/.tsbuildinfo", diff --git a/sample-code/src/orchestration.ts b/sample-code/src/orchestration.ts index bc60f7d37..180496a91 100644 --- a/sample-code/src/orchestration.ts +++ b/sample-code/src/orchestration.ts @@ -786,8 +786,7 @@ export async function orchestrationMessageHistoryWithToolCalling(): Promise { const client = new OrchestrationClient({ promptTemplating: { - model: { name: 'anthropic--claude-4.5-haiku' }, - prompt: { template: [{ role: 'system', content: 'You are helpful.' }] } + model: { name: 'anthropic--claude-4.5-haiku' } } }); From 3022bfd0dff278b07500652f84f0910576c6ed76 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Mon, 6 Jul 2026 14:46:52 +0200 Subject: [PATCH 12/49] fix: also disable auto-routing when prompt has tools but no template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prompt.tools (without template) still means the user opted into orchestration's prompt handling — auto-routing tool messages to messages_history in this case leaves messages empty and throws 'Either a prompt template or messages must be defined'. Extend hasStaticPrompt check to cover prompt.tools so that multi-turn tool-calling flows (orchestrationMessageHistoryWithToolCalling) are not broken by the auto-routing logic. --- ...rchestration-completion-post-request.test.ts | 17 +++++++++++++++++ .../orchestration/src/util/module-config.ts | 10 +++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index 2acaa8d2e..9724e4d26 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -446,5 +446,22 @@ describe('construct completion post request', () => { result.config.modules.prompt_templating.prompt.template ).toContainEqual(toolMessage); }); + + it('should not auto-route when config has prompt.tools (no template)', () => { + const toolsConfig: OrchestrationModuleConfig = { + promptTemplating: { + model: { name: 'gpt-5.4-nano' }, + prompt: { tools: [{ type: 'function', function: { name: 'search', description: 'search', parameters: {} } }] } + } + }; + const result: any = constructCompletionPostRequest(toolsConfig, { + messages: [assistantMessage, toolMessage, userMessage] + }); + + expect(result.messages_history).toBeUndefined(); + expect( + result.config.modules.prompt_templating.prompt.template + ).toContainEqual(toolMessage); + }); }); }); diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index f86b1d65c..9e668ff16 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -367,18 +367,18 @@ export function constructCompletionPostRequest( // 2. Tool results — their content comes from external systems and may contain {{?...}} // patterns that are not user-defined placeholders. We route through the last tool // message so the assistant+tool pair stays together for tool_call_id validation. - // Auto-routing is skipped when any config has a non-empty prompt.template, because - // in that case the user has opted into templating and the service handles it correctly. + // Auto-routing is skipped when any config has a non-empty prompt.template or prompt.tools, + // because in that case the user has opted into templating and the service handles it correctly. const configs = Array.isArray(config) ? config : [config]; const usesTemplateRef = configs.some(c => isTemplateRef(c?.promptTemplating?.prompt || {}) ); - const hasStaticTemplate = configs.some(c => { + const hasStaticPrompt = configs.some(c => { const prompt = c?.promptTemplating?.prompt; - return isTemplate(prompt) && prompt.template?.length; + return isTemplate(prompt) && (prompt.template?.length || prompt.tools?.length); }); const messages = request?.messages || []; - const lastToolIndex = hasStaticTemplate + const lastToolIndex = hasStaticPrompt ? -1 : messages.findLastIndex(msg => msg.role === 'tool'); const splitIndex = usesTemplateRef ? messages.length : lastToolIndex + 1; From 937d6d28b104afbf33191d9460f2d6f574a77d82 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Mon, 6 Jul 2026 17:06:37 +0200 Subject: [PATCH 13/49] fix: extract split logic, skip routing when placeholder values are set --- .changeset/yummy-ducks-run.md | 2 +- .../orchestration/src/util/module-config.ts | 69 ++++++++++++------- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/.changeset/yummy-ducks-run.md b/.changeset/yummy-ducks-run.md index 19f0c1007..0449b89d8 100644 --- a/.changeset/yummy-ducks-run.md +++ b/.changeset/yummy-ducks-run.md @@ -2,4 +2,4 @@ '@sap-ai-sdk/orchestration': patch --- -[Fix] Route messages preceding tool messages from the `promptTemplating` section `messages` property to `messages_history` automatically if no templating placeholder values are provided. +[Fix] Automatically route `role: 'tool'` messages (and all preceding messages) from `messages` to `messages_history` to bypass prompt templating, unless placeholder values are provided. diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 9e668ff16..180487f0f 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -11,6 +11,7 @@ import { type EmbeddingRequest } from '../orchestration-types.js'; import type { + ChatMessage, CompletionPostRequest, CompletionRequestConfigurationReferenceById, CompletionRequestConfigurationReferenceByNameScenarioVersion, @@ -358,37 +359,16 @@ export function constructCompletionPostRequest( // - Single config (OrchestrationModuleConfig) → single ModuleConfigs object // - Config array (OrchestrationModuleConfigList) → array of ModuleConfigs for fallback behavior - // Determine where to split request.messages: everything before splitIndex is routed - // to messages_history (bypassing prompt templating); everything from splitIndex onward - // stays in messages and is merged into prompt.template. - // - // Two cases route messages out of templating: - // 1. TemplateRef configs — the template lives remotely, so messages cannot be merged in. - // 2. Tool results — their content comes from external systems and may contain {{?...}} - // patterns that are not user-defined placeholders. We route through the last tool - // message so the assistant+tool pair stays together for tool_call_id validation. - // Auto-routing is skipped when any config has a non-empty prompt.template or prompt.tools, - // because in that case the user has opted into templating and the service handles it correctly. const configs = Array.isArray(config) ? config : [config]; - const usesTemplateRef = configs.some(c => - isTemplateRef(c?.promptTemplating?.prompt || {}) - ); - const hasStaticPrompt = configs.some(c => { - const prompt = c?.promptTemplating?.prompt; - return isTemplate(prompt) && (prompt.template?.length || prompt.tools?.length); - }); const messages = request?.messages || []; - const lastToolIndex = hasStaticPrompt - ? -1 - : messages.findLastIndex(msg => msg.role === 'tool'); - const splitIndex = usesTemplateRef ? messages.length : lastToolIndex + 1; + const splitIndex = getMessageSplitIndex(configs, messages, request); - const remainingMessages = messages.slice(splitIndex); let moduleRequest = request; if (splitIndex > 0 && request) { + const remaining = messages.slice(splitIndex); moduleRequest = { ...request, - messages: remainingMessages.length ? remainingMessages : undefined + messages: remaining.length ? remaining : undefined }; } @@ -464,6 +444,47 @@ function buildCompletionModulesConfig( }; } +/** + * Determines the split index for routing messages to messages_history. + * Messages before splitIndex bypass prompt templating; messages from splitIndex onward + * stay in prompt.template. + * + * Routing is skipped when: + * - placeholder values are set (user has opted into templating) + * - any config has a non-empty static prompt.template or prompt.tools + * + * When a TemplateRef is used, all messages are routed (splitIndex = messages.length). + */ +function getMessageSplitIndex( + configs: OrchestrationModuleConfig[], + messages: ChatMessage[], + request?: ChatCompletionRequest +): number { + const usesTemplateRef = configs.some(c => + isTemplateRef(c?.promptTemplating?.prompt || {}) + ); + if (usesTemplateRef) { + return messages.length; + } + + const hasPlaceholderValues = + !!request?.placeholderValues && + Object.keys(request.placeholderValues).length > 0; + if (hasPlaceholderValues) { + return 0; + } + + const hasStaticPrompt = configs.some(c => { + const prompt = c?.promptTemplating?.prompt; + return isTemplate(prompt) && (prompt.template?.length || prompt.tools?.length); + }); + if (hasStaticPrompt) { + return 0; + } + + return messages.findLastIndex(msg => msg.role === 'tool') + 1; +} + function isTemplate(templating: unknown): templating is Template { return ( !!templating && From 8f49badf0f96d843a1202ac8c26bcfe716ac9f37 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Tue, 7 Jul 2026 10:52:08 +0200 Subject: [PATCH 14/49] fix: add missing JSDoc params to getMessageSplitIndex --- ...hestration-completion-post-request.test.ts | 19 +++++++++++++++++-- .../orchestration/src/orchestration-types.ts | 3 ++- .../orchestration/src/util/module-config.ts | 10 ++++++++-- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index 9724e4d26..2e967fe07 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -367,7 +367,11 @@ describe('construct completion post request', () => { it('should route tool messages from messages to messages_history', () => { const result: any = constructCompletionPostRequest(noTemplateConfig, { - messages: [userMessage, toolMessage, { role: 'user' as const, content: 'Follow up.' }] + messages: [ + userMessage, + toolMessage, + { role: 'user' as const, content: 'Follow up.' } + ] }); expect(result.messages_history).toContainEqual(userMessage); @@ -451,7 +455,18 @@ describe('construct completion post request', () => { const toolsConfig: OrchestrationModuleConfig = { promptTemplating: { model: { name: 'gpt-5.4-nano' }, - prompt: { tools: [{ type: 'function', function: { name: 'search', description: 'search', parameters: {} } }] } + prompt: { + tools: [ + { + type: 'function', + function: { + name: 'search', + description: 'search', + parameters: {} + } + } + ] + } } }; const result: any = constructCompletionPostRequest(toolsConfig, { diff --git a/packages/orchestration/src/orchestration-types.ts b/packages/orchestration/src/orchestration-types.ts index bf06e09a6..095f69419 100644 --- a/packages/orchestration/src/orchestration-types.ts +++ b/packages/orchestration/src/orchestration-types.ts @@ -681,7 +681,8 @@ export interface DocumentTranslationApplyToSelector { * Target language for translation, either a language code or a selector configuration. */ export type TranslationTargetLanguage = - string | DocumentTranslationApplyToSelector; + | string + | DocumentTranslationApplyToSelector; /** * Input parameters for translation configuration. diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 180487f0f..143458d5e 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -451,9 +451,13 @@ function buildCompletionModulesConfig( * * Routing is skipped when: * - placeholder values are set (user has opted into templating) - * - any config has a non-empty static prompt.template or prompt.tools + * - any config has a non-empty static prompt.template or prompt.tools. * * When a TemplateRef is used, all messages are routed (splitIndex = messages.length). + * @param configs - The orchestration module configurations. + * @param messages - The chat messages to evaluate. + * @param request - The optional chat completion request containing placeholder values. + * @returns The index at which to split messages between messages_history and prompt.template. */ function getMessageSplitIndex( configs: OrchestrationModuleConfig[], @@ -476,7 +480,9 @@ function getMessageSplitIndex( const hasStaticPrompt = configs.some(c => { const prompt = c?.promptTemplating?.prompt; - return isTemplate(prompt) && (prompt.template?.length || prompt.tools?.length); + return ( + isTemplate(prompt) && (prompt.template?.length || prompt.tools?.length) + ); }); if (hasStaticPrompt) { return 0; From e02c07d64cc854db7f0aec7c186c6adda97c3f8a Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Tue, 7 Jul 2026 11:15:22 +0200 Subject: [PATCH 15/49] fix: add lib es2023 to tsconfig.base.json for findLastIndex support --- tsconfig.base.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.base.json b/tsconfig.base.json index 42dfb4a53..e472afeeb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "ES2022", + "lib": ["es2023"], "module": "Node16", "declaration": true, "declarationMap": true, From a18ed9b0d3911fc378d9f11b8319ec7e9162986a Mon Sep 17 00:00:00 2001 From: "sap-ai-sdk-bot[bot]" <272306433+sap-ai-sdk-bot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:16:32 +0000 Subject: [PATCH 16/49] fix: Changes from lint --- packages/orchestration/src/orchestration-types.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/orchestration/src/orchestration-types.ts b/packages/orchestration/src/orchestration-types.ts index 095f69419..bf06e09a6 100644 --- a/packages/orchestration/src/orchestration-types.ts +++ b/packages/orchestration/src/orchestration-types.ts @@ -681,8 +681,7 @@ export interface DocumentTranslationApplyToSelector { * Target language for translation, either a language code or a selector configuration. */ export type TranslationTargetLanguage = - | string - | DocumentTranslationApplyToSelector; + string | DocumentTranslationApplyToSelector; /** * Input parameters for translation configuration. From eb5dd487290226fb44c8874090a68d2292022524 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Tue, 7 Jul 2026 15:33:56 +0200 Subject: [PATCH 17/49] fix: omit messages key entirely when no remaining messages --- packages/orchestration/src/util/module-config.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 143458d5e..95aaa04ea 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -366,9 +366,10 @@ export function constructCompletionPostRequest( let moduleRequest = request; if (splitIndex > 0 && request) { const remaining = messages.slice(splitIndex); + const { messages: _messages, ...rest } = request; moduleRequest = { - ...request, - messages: remaining.length ? remaining : undefined + ...rest, + ...(remaining.length && { messages: remaining }) }; } From 6d23c8b042e8869f1cf0f1b7f5e5004016143dfd Mon Sep 17 00:00:00 2001 From: Injun Park Date: Wed, 8 Jul 2026 13:21:55 +0200 Subject: [PATCH 18/49] Apply suggestions from code review Co-authored-by: David Knaack --- packages/orchestration/src/util/module-config.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 95aaa04ea..515f29f90 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -479,15 +479,6 @@ function getMessageSplitIndex( return 0; } - const hasStaticPrompt = configs.some(c => { - const prompt = c?.promptTemplating?.prompt; - return ( - isTemplate(prompt) && (prompt.template?.length || prompt.tools?.length) - ); - }); - if (hasStaticPrompt) { - return 0; - } return messages.findLastIndex(msg => msg.role === 'tool') + 1; } From 92d489793c8c41e1fde2bd997690924d1532a72c Mon Sep 17 00:00:00 2001 From: "sap-ai-sdk-bot[bot]" <272306433+sap-ai-sdk-bot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:23:05 +0000 Subject: [PATCH 19/49] fix: Changes from lint --- packages/orchestration/src/util/module-config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 515f29f90..d78307bc8 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -479,7 +479,6 @@ function getMessageSplitIndex( return 0; } - return messages.findLastIndex(msg => msg.role === 'tool') + 1; } From 2f3103199f2ee9306051ec64e59e72d299257631 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Wed, 8 Jul 2026 16:09:28 +0200 Subject: [PATCH 20/49] fix after david's feedback --- .changeset/yummy-ducks-run.md | 2 +- ...hestration-completion-post-request.test.ts | 19 ++++++---- .../orchestration/src/util/module-config.ts | 27 ++++--------- sample-code/src/index.ts | 3 +- sample-code/src/orchestration.ts | 38 +++++++++++++++++++ tests/e2e-tests/src/orchestration.test.ts | 12 +++++- 6 files changed, 72 insertions(+), 29 deletions(-) diff --git a/.changeset/yummy-ducks-run.md b/.changeset/yummy-ducks-run.md index 0449b89d8..1d82fe7b1 100644 --- a/.changeset/yummy-ducks-run.md +++ b/.changeset/yummy-ducks-run.md @@ -2,4 +2,4 @@ '@sap-ai-sdk/orchestration': patch --- -[Fix] Automatically route `role: 'tool'` messages (and all preceding messages) from `messages` to `messages_history` to bypass prompt templating, unless placeholder values are provided. +[Fix] Automatically route `role: 'tool'` messages (and all preceding messages) from `messages` to `messages_history` to bypass prompt templating, unless placeholder values are provided. When no `prompt` property is set on the config, all messages are routed to `messages_history`. diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index 2e967fe07..24bd97ae2 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -439,19 +439,21 @@ describe('construct completion post request', () => { expect(result.messages_history).toEqual([assistantMessage]); }); - it('should not auto-route when config has a static prompt template', () => { - // static template present → auto-routing disabled, tool message stays in prompt.template + it('should auto-route tool messages even when config has a static prompt template', () => { const result: any = constructCompletionPostRequest(defaultConfig, { messages: [assistantMessage, toolMessage, userMessage] }); - expect(result.messages_history).toBeUndefined(); + expect(result.messages_history).toEqual([assistantMessage, toolMessage]); expect( result.config.modules.prompt_templating.prompt.template - ).toContainEqual(toolMessage); + ).not.toContainEqual(toolMessage); + expect( + result.config.modules.prompt_templating.prompt.template + ).toContainEqual(userMessage); }); - it('should not auto-route when config has prompt.tools (no template)', () => { + it('should auto-route tool messages when config has prompt.tools (no template)', () => { const toolsConfig: OrchestrationModuleConfig = { promptTemplating: { model: { name: 'gpt-5.4-nano' }, @@ -473,10 +475,13 @@ describe('construct completion post request', () => { messages: [assistantMessage, toolMessage, userMessage] }); - expect(result.messages_history).toBeUndefined(); + expect(result.messages_history).toEqual([assistantMessage, toolMessage]); expect( result.config.modules.prompt_templating.prompt.template - ).toContainEqual(toolMessage); + ).toContainEqual(userMessage); + expect( + result.config.modules.prompt_templating.prompt.template + ).not.toContainEqual(toolMessage); }); }); }); diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index d78307bc8..9efe8a892 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -416,12 +416,9 @@ function buildCompletionModulesConfig( config; // prompt is not a string here as it is already parsed in `parseAndMergeTemplating` method - const prompt = { - ...(promptTemplating.prompt as Template | TemplateRef) - }; - - // If promptTemplating.prompt is not defined, we initialize it with an empty Template object - promptTemplating.prompt = promptTemplating.prompt || { template: [] }; + const prompt: Template | TemplateRef = promptTemplating.prompt + ? { ...(promptTemplating.prompt as Template | TemplateRef) } + : { template: [] }; if (isTemplate(prompt)) { if (!prompt.template?.length && !request?.messages?.length) { @@ -452,9 +449,9 @@ function buildCompletionModulesConfig( * * Routing is skipped when: * - placeholder values are set (user has opted into templating) - * - any config has a non-empty static prompt.template or prompt.tools. + * - any config has a `prompt` property (Template or TemplateRef) * - * When a TemplateRef is used, all messages are routed (splitIndex = messages.length). + * When no config has a `prompt` property, all messages are routed (splitIndex = messages.length). * @param configs - The orchestration module configurations. * @param messages - The chat messages to evaluate. * @param request - The optional chat completion request containing placeholder values. @@ -465,10 +462,10 @@ function getMessageSplitIndex( messages: ChatMessage[], request?: ChatCompletionRequest ): number { - const usesTemplateRef = configs.some(c => - isTemplateRef(c?.promptTemplating?.prompt || {}) + const usesTemplate = configs.some(c => + c?.promptTemplating?.hasOwnProperty('prompt') ); - if (usesTemplateRef) { + if (!usesTemplate) { return messages.length; } @@ -490,14 +487,6 @@ function isTemplate(templating: unknown): templating is Template { ); } -function isTemplateRef(templating: unknown): templating is TemplateRef { - return ( - !!templating && - typeof templating === 'object' && - 'template_ref' in templating - ); -} - /** * Constructs an embedding post request from the given configuration and request. * @internal diff --git a/sample-code/src/index.ts b/sample-code/src/index.ts index 57c1bb165..93e92f860 100644 --- a/sample-code/src/index.ts +++ b/sample-code/src/index.ts @@ -37,7 +37,8 @@ export { orchestrationSonarStreamWithCitations, orchestrationStreamWithFallbackConfigs, orchestrationToolResultInMessages, - orchestrationToolResultMaskingInMessagesHistory + orchestrationToolResultMaskingInMessagesHistory, + orchestrationToolLastMessageInMessages } from './orchestration.js'; export { invoke, diff --git a/sample-code/src/orchestration.ts b/sample-code/src/orchestration.ts index 180496a91..a91a40a9d 100644 --- a/sample-code/src/orchestration.ts +++ b/sample-code/src/orchestration.ts @@ -864,6 +864,44 @@ export async function orchestrationToolResultMaskingInMessagesHistory(): Promise }); } +/** + * Verify that the SDK correctly routes all messages to messages_history when + * the last message is a tool result (no trailing user message). + * @returns The orchestration service response. + */ +export async function orchestrationToolLastMessageInMessages(): Promise { + const client = new OrchestrationClient({ + promptTemplating: { + model: { name: 'anthropic--claude-4.5-haiku' } + } + }); + + const assistantMessage: AssistantChatMessage = { + role: 'assistant', + tool_calls: [ + { + id: 'call_abc123', + type: 'function', + function: { name: 'lookup', arguments: '{"id":"1"}' } + } + ] + }; + + const toolMessage: ToolChatMessage = { + role: 'tool', + content: 'Result: done', + tool_call_id: 'call_abc123' + }; + + return client.chatCompletion({ + messages: [ + { role: 'user', content: 'Call the lookup tool.' }, + assistantMessage, + toolMessage + ] + }); +} + /** * Use translation module for input and output translation with advanced features. * @returns The orchestration service response. diff --git a/tests/e2e-tests/src/orchestration.test.ts b/tests/e2e-tests/src/orchestration.test.ts index e569fa88e..f4b57f411 100644 --- a/tests/e2e-tests/src/orchestration.test.ts +++ b/tests/e2e-tests/src/orchestration.test.ts @@ -26,7 +26,8 @@ import { orchestrationSonarStreamWithCitations, orchestrationStreamWithFallbackConfigs, orchestrationToolResultInMessages, - orchestrationToolResultMaskingInMessagesHistory + orchestrationToolResultMaskingInMessagesHistory, + orchestrationToolLastMessageInMessages } from '@sap-ai-sdk/sample-code'; import { OrchestrationClient, @@ -358,5 +359,14 @@ describe('orchestration', () => { roles.lastIndexOf('tool') ); }); + + it('should succeed when the last message is of type tool', async () => { + const response = await orchestrationToolLastMessageInMessages(); + expect(response.getContent()).toEqual(expect.any(String)); + + const templating = response.getIntermediateResults().templating; + const roles = templating!.map(m => m.role); + expect(roles[roles.length - 1]).toBe('tool'); + }); }); }); From 22317d3e31c1ad19c825646e44a9f1e0f5b8faaf Mon Sep 17 00:00:00 2001 From: "sap-ai-sdk-bot[bot]" <272306433+sap-ai-sdk-bot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:10:54 +0000 Subject: [PATCH 21/49] fix: Changes from lint --- packages/orchestration/src/util/module-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 9efe8a892..6ba1da077 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -449,7 +449,7 @@ function buildCompletionModulesConfig( * * Routing is skipped when: * - placeholder values are set (user has opted into templating) - * - any config has a `prompt` property (Template or TemplateRef) + * - any config has a `prompt` property (Template or TemplateRef). * * When no config has a `prompt` property, all messages are routed (splitIndex = messages.length). * @param configs - The orchestration module configurations. From 1b2f9933619dbea60e2dff80d2d923f0923775ae Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Wed, 8 Jul 2026 16:30:42 +0200 Subject: [PATCH 22/49] fix for ci --- packages/orchestration/src/util/module-config.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 6ba1da077..6a1272781 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -421,7 +421,11 @@ function buildCompletionModulesConfig( : { template: [] }; if (isTemplate(prompt)) { - if (!prompt.template?.length && !request?.messages?.length) { + if ( + promptTemplating.prompt && + !prompt.template?.length && + !request?.messages?.length + ) { throw new Error('Either a prompt template or messages must be defined.'); } prompt.template = [ @@ -469,6 +473,14 @@ function getMessageSplitIndex( return messages.length; } + const usesTemplateRef = configs.some(c => { + const p = c?.promptTemplating?.prompt; + return !!p && typeof p === 'object' && 'template_ref' in p; + }); + if (usesTemplateRef) { + return messages.length; + } + const hasPlaceholderValues = !!request?.placeholderValues && Object.keys(request.placeholderValues).length > 0; From 16c7c951fc42d092d569a08c1522898c8d50bc4a Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Wed, 8 Jul 2026 20:43:02 +0200 Subject: [PATCH 23/49] fix ci error --- .../src/orchestration/client.test.ts | 166 ++---------------- 1 file changed, 13 insertions(+), 153 deletions(-) diff --git a/packages/langchain/src/orchestration/client.test.ts b/packages/langchain/src/orchestration/client.test.ts index 0794b99e6..f92c073cb 100644 --- a/packages/langchain/src/orchestration/client.test.ts +++ b/packages/langchain/src/orchestration/client.test.ts @@ -81,19 +81,7 @@ describe('orchestration service client', () => { ) { mockInference( { - data: constructCompletionPostRequest( - { - ...config, - promptTemplating: { - ...config.promptTemplating, - prompt: { - template: messages - } - } - }, - { messages: [] }, - isStream - ) + data: constructCompletionPostRequest(config, { messages }, isStream) }, { data: response, @@ -403,19 +391,7 @@ describe('orchestration service client', () => { it('supports streaming responses', async () => { mockInference( { - data: constructCompletionPostRequest( - { - ...config, - promptTemplating: { - ...config.promptTemplating, - prompt: { - template: messages - } - } - }, - { messages: [] }, - true - ) + data: constructCompletionPostRequest(config, { messages }, true) }, { data: mockResponseStream, @@ -437,19 +413,7 @@ describe('orchestration service client', () => { it('supports auto-streaming responses', async () => { mockInference( { - data: constructCompletionPostRequest( - { - ...config, - promptTemplating: { - ...config.promptTemplating, - prompt: { - template: messages - } - } - }, - { messages: [] }, - true - ) + data: constructCompletionPostRequest(config, { messages }, true) }, { data: mockResponseStream, @@ -476,19 +440,7 @@ describe('orchestration service client', () => { it('has langchain handle disabling streaming via disableStreaming flag in stream', async () => { mockInference( { - data: constructCompletionPostRequest( - { - ...config, - promptTemplating: { - ...config.promptTemplating, - prompt: { - template: messages - } - } - }, - { messages: [] }, - false - ) + data: constructCompletionPostRequest(config, { messages }, false) }, { data: mockResponse, @@ -552,19 +504,7 @@ describe('orchestration service client', () => { it('streams and aborts with a signal', async () => { mockInference( { - data: constructCompletionPostRequest( - { - ...config, - promptTemplating: { - ...config.promptTemplating, - prompt: { - template: messages - } - } - }, - { messages: [] }, - true - ) + data: constructCompletionPostRequest(config, { messages }, true) }, { data: mockResponseStream, @@ -588,19 +528,7 @@ describe('orchestration service client', () => { it('streams with a callback', async () => { mockInference( { - data: constructCompletionPostRequest( - { - ...config, - promptTemplating: { - ...config.promptTemplating, - prompt: { - template: messages - } - } - }, - { messages: [] }, - true - ) + data: constructCompletionPostRequest(config, { messages }, true) }, { data: mockResponseStream, @@ -636,19 +564,7 @@ describe('orchestration service client', () => { it('supports streaming responses with tool calls', async () => { mockInference( { - data: constructCompletionPostRequest( - { - ...config, - promptTemplating: { - ...config.promptTemplating, - prompt: { - template: messages - } - } - }, - { messages: [] }, - true - ) + data: constructCompletionPostRequest(config, { messages }, true) }, { data: mockResponseStreamToolCalls, @@ -677,19 +593,7 @@ describe('orchestration service client', () => { it('streams when invoked in a streaming langgraph', async () => { mockInference( { - data: constructCompletionPostRequest( - { - ...config, - promptTemplating: { - ...config.promptTemplating, - prompt: { - template: messages - } - } - }, - { messages: [] }, - true - ) + data: constructCompletionPostRequest(config, { messages }, true) }, { data: mockResponseStream, @@ -755,27 +659,8 @@ describe('orchestration service client', () => { mockInference( { data: constructCompletionPostRequest( - [ - { - ...primaryConfig, - promptTemplating: { - ...primaryConfig.promptTemplating, - prompt: { - template: messages - } - } - }, - { - ...fallbackConfig, - promptTemplating: { - ...fallbackConfig.promptTemplating, - prompt: { - template: messages - } - } - } - ], - { messages: [] } + [primaryConfig, fallbackConfig], + { messages } ) }, { @@ -812,27 +697,8 @@ describe('orchestration service client', () => { mockInference( { data: constructCompletionPostRequest( - [ - { - ...primaryConfig, - promptTemplating: { - ...primaryConfig.promptTemplating, - prompt: { - template: messages - } - } - }, - { - ...fallbackConfig, - promptTemplating: { - ...fallbackConfig.promptTemplating, - prompt: { - template: messages - } - } - } - ], - { messages: [] }, + [primaryConfig, fallbackConfig], + { messages }, true ) }, @@ -891,9 +757,6 @@ describe('orchestration service client', () => { ...primaryConfig.promptTemplating.model.params, stop: ['PRIMARY_STOP', 'END'] } - }, - prompt: { - template: messages } } }, @@ -907,14 +770,11 @@ describe('orchestration service client', () => { ...fallbackConfig.promptTemplating.model.params, stop: ['FALLBACK_STOP', 'END'] } - }, - prompt: { - template: messages } } } ], - { messages: [] } + { messages } ) }, { From 8cf9a2d8ecea1df179ab5db801aae6771b9a4e57 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Wed, 8 Jul 2026 20:52:27 +0200 Subject: [PATCH 24/49] fix ci error --- packages/langchain/src/orchestration/client.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/langchain/src/orchestration/client.test.ts b/packages/langchain/src/orchestration/client.test.ts index f92c073cb..4df6854f2 100644 --- a/packages/langchain/src/orchestration/client.test.ts +++ b/packages/langchain/src/orchestration/client.test.ts @@ -77,11 +77,16 @@ describe('orchestration service client', () => { delay?: number; }, status: number = 200, - isStream?: boolean + isStream?: boolean, + inputMessages = messages ) { mockInference( { - data: constructCompletionPostRequest(config, { messages }, isStream) + data: constructCompletionPostRequest( + config, + { messages: inputMessages }, + isStream + ) }, { data: response, @@ -157,7 +162,8 @@ describe('orchestration service client', () => { mockResponseStream, { delay: 2000 }, 200, - true + true, + [] ); let finalOutput: AIMessageChunk | undefined; From 6048e391925a94c3abf1a6bffc6b1b4d898e2f06 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Wed, 8 Jul 2026 20:58:41 +0200 Subject: [PATCH 25/49] fix ci error(grammar on yummy-ducks-run --- .changeset/yummy-ducks-run.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/yummy-ducks-run.md b/.changeset/yummy-ducks-run.md index 1d82fe7b1..386a03594 100644 --- a/.changeset/yummy-ducks-run.md +++ b/.changeset/yummy-ducks-run.md @@ -2,4 +2,5 @@ '@sap-ai-sdk/orchestration': patch --- -[Fix] Automatically route `role: 'tool'` messages (and all preceding messages) from `messages` to `messages_history` to bypass prompt templating, unless placeholder values are provided. When no `prompt` property is set on the config, all messages are routed to `messages_history`. +[Fix] Automatically route `role: 'tool'` messages (and all preceding messages) from `messages` to `messages_history` to bypass prompt templating, unless placeholder values are provided. +When no `prompt` property is set on the config, all messages are routed to `messages_history`. From 64a6c631568a34c1a200d0318ab945dc0f19c310 Mon Sep 17 00:00:00 2001 From: Injun Park Date: Fri, 10 Jul 2026 10:23:55 +0200 Subject: [PATCH 26/49] Update packages/orchestration/src/util/module-config.ts Co-authored-by: David Knaack --- packages/orchestration/src/util/module-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 6a1272781..483a92312 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -483,7 +483,7 @@ function getMessageSplitIndex( const hasPlaceholderValues = !!request?.placeholderValues && - Object.keys(request.placeholderValues).length > 0; + Object.keys(request.placeholderValues).length; if (hasPlaceholderValues) { return 0; } From 2901e34b09b332529b7690626683810c78ae33c3 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Fri, 10 Jul 2026 12:47:04 +0200 Subject: [PATCH 27/49] fix: replace getMessageSplitIndex with shouldRouteMessagesToHistory boolean --- .changeset/yummy-ducks-run.md | 5 +- ...hestration-completion-post-request.test.ts | 71 +++++------- .../orchestration/src/orchestration-types.ts | 9 +- .../orchestration/src/util/module-config.ts | 103 +++++++++--------- 4 files changed, 84 insertions(+), 104 deletions(-) diff --git a/.changeset/yummy-ducks-run.md b/.changeset/yummy-ducks-run.md index 386a03594..5966f6790 100644 --- a/.changeset/yummy-ducks-run.md +++ b/.changeset/yummy-ducks-run.md @@ -2,5 +2,6 @@ '@sap-ai-sdk/orchestration': patch --- -[Fix] Automatically route `role: 'tool'` messages (and all preceding messages) from `messages` to `messages_history` to bypass prompt templating, unless placeholder values are provided. -When no `prompt` property is set on the config, all messages are routed to `messages_history`. +[Fix] Automatically route all `messages` to `messages_history` to bypass prompt templating when no `prompt` is configured or a `TemplateRef` is used. +Routing is skipped when a `Template` is configured or `placeholderValues` are provided. + diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index 24bd97ae2..f1ed3d22d 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -338,7 +338,7 @@ describe('construct completion post request', () => { expect(completionPostRequest).toEqual(expectedCompletionPostRequest); }); - describe('tool message auto-routing', () => { + describe('messages routing to messages_history', () => { const toolCallId = 'call_abc123'; const assistantMessage = { role: 'assistant' as const, @@ -357,34 +357,28 @@ describe('construct completion post request', () => { }; const userMessage = { role: 'user' as const, content: 'Summarize.' }; - // Config without a static template — auto-routing is active + // Config without a prompt — shouldRouteMessagesToHistory returns true const noTemplateConfig: OrchestrationModuleConfig = { promptTemplating: { - model: { name: 'gpt-5.4-nano' }, - prompt: { template: [] } + model: { name: 'gpt-5.4-nano' } } }; - it('should route tool messages from messages to messages_history', () => { + it('should route all messages to messages_history when no prompt is configured', () => { + const followUp = { role: 'user' as const, content: 'Follow up.' }; const result: any = constructCompletionPostRequest(noTemplateConfig, { messages: [ userMessage, toolMessage, - { role: 'user' as const, content: 'Follow up.' } + followUp ] }); - expect(result.messages_history).toContainEqual(userMessage); - expect(result.messages_history).toContainEqual(toolMessage); - expect( - result.config.modules.prompt_templating.prompt.template - ).not.toContainEqual(toolMessage); - expect( - result.config.modules.prompt_templating.prompt.template - ).not.toContainEqual(userMessage); + expect(result.messages_history).toEqual([userMessage, toolMessage, followUp]); + expect(result.config.modules.prompt_templating.prompt).toBeUndefined(); }); - it('should preserve existing messagesHistory when appending tool messages', () => { + it('should preserve existing messagesHistory when routing all messages', () => { const followUpUser = { role: 'user' as const, content: 'Follow up.' }; const result = constructCompletionPostRequest(noTemplateConfig, { messages: [userMessage, toolMessage, followUpUser], @@ -394,66 +388,56 @@ describe('construct completion post request', () => { expect(result.messages_history).toEqual([ assistantMessage, userMessage, - toolMessage + toolMessage, + followUpUser ]); }); - it('should not affect non-tool messages', () => { + it('should route all messages to history even without tool messages', () => { const result: any = constructCompletionPostRequest(noTemplateConfig, { messages: [userMessage] }); - expect(result.messages_history).toBeUndefined(); - expect( - result.config.modules.prompt_templating.prompt.template - ).toContainEqual(userMessage); + expect(result.messages_history).toEqual([userMessage]); + expect(result.config.modules.prompt_templating.prompt).toBeUndefined(); }); - it('should preserve chronological message order across the split', () => { + it('should preserve chronological message order in messages_history', () => { const followUpUser = { role: 'user' as const, content: 'Follow up.' }; const result: any = constructCompletionPostRequest(noTemplateConfig, { messages: [userMessage, assistantMessage, toolMessage, followUpUser] }); - // messages before and including last tool go to messages_history in order expect(result.messages_history).toEqual([ userMessage, assistantMessage, - toolMessage + toolMessage, + followUpUser ]); - // only messages after the last tool stay in prompt.template - expect( - result.config.modules.prompt_templating.prompt.template - ).toContainEqual(followUpUser); - expect( - result.config.modules.prompt_templating.prompt.template - ).not.toContainEqual(toolMessage); + expect(result.config.modules.prompt_templating.prompt).toBeUndefined(); }); - it('should emit messages_history when only messagesHistory is provided (no tool messages)', () => { + it('should combine existing messagesHistory with routed messages', () => { const result = constructCompletionPostRequest(noTemplateConfig, { messages: [userMessage], messagesHistory: [assistantMessage] }); - expect(result.messages_history).toEqual([assistantMessage]); + expect(result.messages_history).toEqual([assistantMessage, userMessage]); }); - it('should auto-route tool messages even when config has a static prompt template', () => { + it('should not route messages when config has a static prompt template', () => { const result: any = constructCompletionPostRequest(defaultConfig, { messages: [assistantMessage, toolMessage, userMessage] }); - expect(result.messages_history).toEqual([assistantMessage, toolMessage]); - expect( - result.config.modules.prompt_templating.prompt.template - ).not.toContainEqual(toolMessage); + expect(result.messages_history).toBeUndefined(); expect( result.config.modules.prompt_templating.prompt.template - ).toContainEqual(userMessage); + ).toContainEqual(toolMessage); }); - it('should auto-route tool messages when config has prompt.tools (no template)', () => { + it('should not route messages when config has prompt.tools', () => { const toolsConfig: OrchestrationModuleConfig = { promptTemplating: { model: { name: 'gpt-5.4-nano' }, @@ -475,13 +459,10 @@ describe('construct completion post request', () => { messages: [assistantMessage, toolMessage, userMessage] }); - expect(result.messages_history).toEqual([assistantMessage, toolMessage]); - expect( - result.config.modules.prompt_templating.prompt.template - ).toContainEqual(userMessage); + expect(result.messages_history).toBeUndefined(); expect( result.config.modules.prompt_templating.prompt.template - ).not.toContainEqual(toolMessage); + ).toContainEqual(toolMessage); }); }); }); diff --git a/packages/orchestration/src/orchestration-types.ts b/packages/orchestration/src/orchestration-types.ts index bf06e09a6..cb7928039 100644 --- a/packages/orchestration/src/orchestration-types.ts +++ b/packages/orchestration/src/orchestration-types.ts @@ -46,10 +46,11 @@ export interface ChatCompletionRequest { /** * New chat messages, including template messages. - * Messages with `role: 'tool'` — and all messages preceding them — are automatically - * routed to `messages_history` to bypass prompt templating. This prevents tool results - * from external systems containing `{{?...}}` syntax from being misinterpreted as - * template placeholders. To verify the final message order sent to the LLM, use + * All messages are automatically routed to `messages_history` when no `prompt` is configured + * or a `TemplateRef` is used, bypassing prompt templating entirely. + * When a `Template` is configured, messages are merged into `prompt.template` as usual. + * Routing is skipped when `placeholderValues` are provided. + * To verify the final message order sent to the LLM, use * `response.getIntermediateResults().templating`. * @example * messages: [ diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 483a92312..b80eb48a0 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -11,7 +11,6 @@ import { type EmbeddingRequest } from '../orchestration-types.js'; import type { - ChatMessage, CompletionPostRequest, CompletionRequestConfigurationReferenceById, CompletionRequestConfigurationReferenceByNameScenarioVersion, @@ -360,17 +359,12 @@ export function constructCompletionPostRequest( // - Config array (OrchestrationModuleConfigList) → array of ModuleConfigs for fallback behavior const configs = Array.isArray(config) ? config : [config]; - const messages = request?.messages || []; - const splitIndex = getMessageSplitIndex(configs, messages, request); + const routeToHistory = shouldRouteMessagesToHistory(configs, request); let moduleRequest = request; - if (splitIndex > 0 && request) { - const remaining = messages.slice(splitIndex); + if (routeToHistory && request) { const { messages: _messages, ...rest } = request; - moduleRequest = { - ...rest, - ...(remaining.length && { messages: remaining }) - }; + moduleRequest = rest; } /** @@ -393,8 +387,11 @@ export function constructCompletionPostRequest( : { modules: moduleConfigurations }; const messagesHistory = - splitIndex > 0 || request?.messagesHistory?.length - ? [...(request?.messagesHistory || []), ...messages.slice(0, splitIndex)] + routeToHistory || request?.messagesHistory?.length + ? [ + ...(request?.messagesHistory || []), + ...(request?.messages || []) + ] : undefined; return { @@ -415,17 +412,30 @@ function buildCompletionModulesConfig( const { promptTemplating, filtering, masking, grounding, translation } = config; + const modules = { + ...(filtering && Object.keys(filtering).length && { filtering }), + ...(masking && Object.keys(masking).length && { masking }), + ...(grounding && Object.keys(grounding).length && { grounding }), + ...(translation && Object.keys(translation).length && { translation }) + }; + // prompt is not a string here as it is already parsed in `parseAndMergeTemplating` method - const prompt: Template | TemplateRef = promptTemplating.prompt - ? { ...(promptTemplating.prompt as Template | TemplateRef) } - : { template: [] }; + if (!promptTemplating.prompt) { + // No prompt configured: messages are already routed to messages_history upstream. + // Omit the prompt key entirely — it is optional per the service API. + const { prompt: _prompt, ...promptTemplatingWithoutPrompt } = promptTemplating; + return { + prompt_templating: promptTemplatingWithoutPrompt as PromptTemplatingModuleConfig, + ...modules + }; + } + + const prompt: Template | TemplateRef = { + ...(promptTemplating.prompt as Template | TemplateRef) + }; if (isTemplate(prompt)) { - if ( - promptTemplating.prompt && - !prompt.template?.length && - !request?.messages?.length - ) { + if (!prompt.template?.length && !request?.messages?.length) { throw new Error('Either a prompt template or messages must be defined.'); } prompt.template = [ @@ -439,56 +449,43 @@ function buildCompletionModulesConfig( ...promptTemplating, prompt }, - ...(filtering && Object.keys(filtering).length && { filtering }), - ...(masking && Object.keys(masking).length && { masking }), - ...(grounding && Object.keys(grounding).length && { grounding }), - ...(translation && Object.keys(translation).length && { translation }) + ...modules }; } /** - * Determines the split index for routing messages to messages_history. - * Messages before splitIndex bypass prompt templating; messages from splitIndex onward - * stay in prompt.template. + * Determines whether all messages should be routed to messages_history, + * bypassing prompt templating entirely. * - * Routing is skipped when: - * - placeholder values are set (user has opted into templating) - * - any config has a `prompt` property (Template or TemplateRef). + * Routes to history when: + * - no `prompt` is configured (messages have nowhere to be merged) + * - a TemplateRef is used (remote template, messages cannot be merged in) * - * When no config has a `prompt` property, all messages are routed (splitIndex = messages.length). + * Does NOT route to history when: + * - a Template is set (messages are merged into prompt.template) + * - placeholder_values are provided (user has opted into templating) * @param configs - The orchestration module configurations. - * @param messages - The chat messages to evaluate. - * @param request - The optional chat completion request containing placeholder values. - * @returns The index at which to split messages between messages_history and prompt.template. + * @param request - The optional chat completion request. + * @returns True if all messages should be routed to messages_history. */ -function getMessageSplitIndex( +function shouldRouteMessagesToHistory( configs: OrchestrationModuleConfig[], - messages: ChatMessage[], request?: ChatCompletionRequest -): number { - const usesTemplate = configs.some(c => - c?.promptTemplating?.hasOwnProperty('prompt') - ); - if (!usesTemplate) { - return messages.length; - } - - const usesTemplateRef = configs.some(c => { - const p = c?.promptTemplating?.prompt; - return !!p && typeof p === 'object' && 'template_ref' in p; - }); - if (usesTemplateRef) { - return messages.length; - } - +): boolean { const hasPlaceholderValues = !!request?.placeholderValues && Object.keys(request.placeholderValues).length; if (hasPlaceholderValues) { - return 0; + return false; } - return messages.findLastIndex(msg => msg.role === 'tool') + 1; + return configs.some(c => { + const prompt = c?.promptTemplating?.prompt; + if (!prompt) { + return true; + } + return typeof prompt === 'object' && 'template_ref' in prompt; + }); } function isTemplate(templating: unknown): templating is Template { From 424b4624141cc7439573767927ce9a79492c05dd Mon Sep 17 00:00:00 2001 From: "sap-ai-sdk-bot[bot]" <272306433+sap-ai-sdk-bot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:18:31 +0000 Subject: [PATCH 28/49] fix: Changes from lint --- .../orchestration-completion-post-request.test.ts | 12 ++++++------ packages/orchestration/src/util/module-config.ts | 15 +++++++-------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index f1ed3d22d..df7530f29 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -367,14 +367,14 @@ describe('construct completion post request', () => { it('should route all messages to messages_history when no prompt is configured', () => { const followUp = { role: 'user' as const, content: 'Follow up.' }; const result: any = constructCompletionPostRequest(noTemplateConfig, { - messages: [ - userMessage, - toolMessage, - followUp - ] + messages: [userMessage, toolMessage, followUp] }); - expect(result.messages_history).toEqual([userMessage, toolMessage, followUp]); + expect(result.messages_history).toEqual([ + userMessage, + toolMessage, + followUp + ]); expect(result.config.modules.prompt_templating.prompt).toBeUndefined(); }); diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index b80eb48a0..ca537e953 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -388,10 +388,7 @@ export function constructCompletionPostRequest( const messagesHistory = routeToHistory || request?.messagesHistory?.length - ? [ - ...(request?.messagesHistory || []), - ...(request?.messages || []) - ] + ? [...(request?.messagesHistory || []), ...(request?.messages || [])] : undefined; return { @@ -423,9 +420,11 @@ function buildCompletionModulesConfig( if (!promptTemplating.prompt) { // No prompt configured: messages are already routed to messages_history upstream. // Omit the prompt key entirely — it is optional per the service API. - const { prompt: _prompt, ...promptTemplatingWithoutPrompt } = promptTemplating; + const { prompt: _prompt, ...promptTemplatingWithoutPrompt } = + promptTemplating; return { - prompt_templating: promptTemplatingWithoutPrompt as PromptTemplatingModuleConfig, + prompt_templating: + promptTemplatingWithoutPrompt as PromptTemplatingModuleConfig, ...modules }; } @@ -459,11 +458,11 @@ function buildCompletionModulesConfig( * * Routes to history when: * - no `prompt` is configured (messages have nowhere to be merged) - * - a TemplateRef is used (remote template, messages cannot be merged in) + * - a TemplateRef is used (remote template, messages cannot be merged in). * * Does NOT route to history when: * - a Template is set (messages are merged into prompt.template) - * - placeholder_values are provided (user has opted into templating) + * - placeholder_values are provided (user has opted into templating). * @param configs - The orchestration module configurations. * @param request - The optional chat completion request. * @returns True if all messages should be routed to messages_history. From f70451d349e94c3cb6247b328454582d79bcf85d Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Fri, 10 Jul 2026 13:27:32 +0200 Subject: [PATCH 29/49] fix: ensure prompt exists in config when cache_control is used --- packages/langchain/src/orchestration/client.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/langchain/src/orchestration/client.ts b/packages/langchain/src/orchestration/client.ts index 442900d33..ed4eb7be1 100644 --- a/packages/langchain/src/orchestration/client.ts +++ b/packages/langchain/src/orchestration/client.ts @@ -509,6 +509,12 @@ export class OrchestrationClient extends BaseChatModel< } } + // Ensure prompt exists when cache_control is used so messages are merged + // into prompt.template rather than routed to messages_history. + if (options.cache_control) { + config.promptTemplating.prompt ??= {}; + } + return config; } From 415e1dd0657ec8d87f311f07bbf7a6d660c05346 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Fri, 10 Jul 2026 13:30:29 +0200 Subject: [PATCH 30/49] fix: ensure cache_control messages stay in prompt.template --- packages/langchain/src/orchestration/client.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/langchain/src/orchestration/client.test.ts b/packages/langchain/src/orchestration/client.test.ts index 97e9e1de0..e8eb86035 100644 --- a/packages/langchain/src/orchestration/client.test.ts +++ b/packages/langchain/src/orchestration/client.test.ts @@ -1311,12 +1311,8 @@ describe('orchestration service client', () => { mockInference( { data: (body: any) => { - const template = - body?.config?.modules?.prompt_templating?.prompt?.template; - return ( - Array.isArray(template) && - !JSON.stringify(template).includes('cache_control') - ); + const bodyStr = JSON.stringify(body); + return !bodyStr.includes('cache_control'); } }, { data: mockResponse, status: 200 }, From f15904dabda5a071ec6ec822213119790bd641f7 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Mon, 13 Jul 2026 10:13:45 +0200 Subject: [PATCH 31/49] fix: address david feedback on cache_control and TemplateRef routing --- .../src/orchestration/client.test.ts | 9 ++++--- .../langchain/src/orchestration/client.ts | 6 ----- .../orchestration/src/util/module-config.ts | 26 ++++++++++++------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/langchain/src/orchestration/client.test.ts b/packages/langchain/src/orchestration/client.test.ts index e8eb86035..421969082 100644 --- a/packages/langchain/src/orchestration/client.test.ts +++ b/packages/langchain/src/orchestration/client.test.ts @@ -1262,12 +1262,13 @@ describe('orchestration service client', () => { expectedCacheControl: { type: string; ttl?: string } ): (body: any) => boolean { return (body: any): boolean => { - const template = + const messages: any[] = + body?.messages_history ?? body?.config?.modules?.prompt_templating?.prompt?.template; - if (!Array.isArray(template) || messageIdx >= template.length) { + if (!Array.isArray(messages) || messageIdx >= messages.length) { return false; } - const target = template[messageIdx]; + const target = messages[messageIdx]; if (target?.role !== 'user' || !Array.isArray(target.content)) { return false; } @@ -1279,7 +1280,7 @@ describe('orchestration service client', () => { block.cache_control?.type === expectedCacheControl.type && block.cache_control?.ttl === expectedCacheControl.ttl; - const otherMessagesHaveBreakpoint = template.some( + const otherMessagesHaveBreakpoint = messages.some( (msg: any, idx: number) => idx !== messageIdx && JSON.stringify(msg).includes('cache_control') ); diff --git a/packages/langchain/src/orchestration/client.ts b/packages/langchain/src/orchestration/client.ts index ed4eb7be1..442900d33 100644 --- a/packages/langchain/src/orchestration/client.ts +++ b/packages/langchain/src/orchestration/client.ts @@ -509,12 +509,6 @@ export class OrchestrationClient extends BaseChatModel< } } - // Ensure prompt exists when cache_control is used so messages are merged - // into prompt.template rather than routed to messages_history. - if (options.cache_control) { - config.promptTemplating.prompt ??= {}; - } - return config; } diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index ca537e953..c26fa9b20 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -471,20 +471,26 @@ function shouldRouteMessagesToHistory( configs: OrchestrationModuleConfig[], request?: ChatCompletionRequest ): boolean { - const hasPlaceholderValues = + // TemplateRef always routes to history — remote template cannot merge messages, + // regardless of placeholderValues. + const hasTemplateRef = configs.some(c => { + const prompt = c?.promptTemplating?.prompt; + return !!prompt && typeof prompt === 'object' && 'template_ref' in prompt; + }); + if (hasTemplateRef) { + return true; + } + + // placeholderValues means the user opted into templating — do not route. + if ( !!request?.placeholderValues && - Object.keys(request.placeholderValues).length; - if (hasPlaceholderValues) { + Object.keys(request.placeholderValues).length > 0 + ) { return false; } - return configs.some(c => { - const prompt = c?.promptTemplating?.prompt; - if (!prompt) { - return true; - } - return typeof prompt === 'object' && 'template_ref' in prompt; - }); + // No prompt configured — messages have nowhere to be merged into. + return configs.some(c => !c?.promptTemplating?.prompt); } function isTemplate(templating: unknown): templating is Template { From 780ad79f74ac1d8fa00e10f3841fea36c58fac18 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Mon, 13 Jul 2026 16:31:47 +0200 Subject: [PATCH 32/49] fix: rename messages to messageList to avoid variable shadowing in cache_control test --- packages/langchain/src/orchestration/client.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/langchain/src/orchestration/client.test.ts b/packages/langchain/src/orchestration/client.test.ts index 421969082..72dc37a8b 100644 --- a/packages/langchain/src/orchestration/client.test.ts +++ b/packages/langchain/src/orchestration/client.test.ts @@ -1262,13 +1262,13 @@ describe('orchestration service client', () => { expectedCacheControl: { type: string; ttl?: string } ): (body: any) => boolean { return (body: any): boolean => { - const messages: any[] = + const messageList: any[] = body?.messages_history ?? body?.config?.modules?.prompt_templating?.prompt?.template; - if (!Array.isArray(messages) || messageIdx >= messages.length) { + if (!Array.isArray(messageList) || messageIdx >= messageList.length) { return false; } - const target = messages[messageIdx]; + const target = messageList[messageIdx]; if (target?.role !== 'user' || !Array.isArray(target.content)) { return false; } @@ -1280,7 +1280,7 @@ describe('orchestration service client', () => { block.cache_control?.type === expectedCacheControl.type && block.cache_control?.ttl === expectedCacheControl.ttl; - const otherMessagesHaveBreakpoint = messages.some( + const otherMessagesHaveBreakpoint = messageList.some( (msg: any, idx: number) => idx !== messageIdx && JSON.stringify(msg).includes('cache_control') ); From 8dc69d79a33873d0edbe7771cc6b796bb3c5cdf6 Mon Sep 17 00:00:00 2001 From: Injun Park Date: Wed, 15 Jul 2026 10:27:50 +0200 Subject: [PATCH 33/49] Update packages/orchestration/src/util/module-config.ts Co-authored-by: David Knaack --- packages/orchestration/src/util/module-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index c26fa9b20..c75a6d75b 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -490,7 +490,7 @@ function shouldRouteMessagesToHistory( } // No prompt configured — messages have nowhere to be merged into. - return configs.some(c => !c?.promptTemplating?.prompt); + return configs.every(c => !c?.promptTemplating?.prompt); } function isTemplate(templating: unknown): templating is Template { From 2f1c629717cdb27071c2dd257fdbe98bb36c7fcd Mon Sep 17 00:00:00 2001 From: "sap-ai-sdk-bot[bot]" <272306433+sap-ai-sdk-bot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:28:39 +0000 Subject: [PATCH 34/49] fix: Changes from lint --- packages/orchestration/src/util/module-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index c75a6d75b..7a179a067 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -490,7 +490,7 @@ function shouldRouteMessagesToHistory( } // No prompt configured — messages have nowhere to be merged into. - return configs.every(c => !c?.promptTemplating?.prompt); + return configs.every(c => !c?.promptTemplating?.prompt); } function isTemplate(templating: unknown): templating is Template { From 4ee6f52b33955491ab9335cb3c1ab9fd2ef3203e Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Wed, 15 Jul 2026 11:04:28 +0200 Subject: [PATCH 35/49] fix: prevent message duplication and respect placeholderValues before TemplateRef check --- .../orchestration/src/util/module-config.ts | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 7a179a067..b6924d40a 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -388,7 +388,10 @@ export function constructCompletionPostRequest( const messagesHistory = routeToHistory || request?.messagesHistory?.length - ? [...(request?.messagesHistory || []), ...(request?.messages || [])] + ? [ + ...(request?.messagesHistory || []), + ...(routeToHistory ? request?.messages || [] : []) + ] : undefined; return { @@ -471,16 +474,6 @@ function shouldRouteMessagesToHistory( configs: OrchestrationModuleConfig[], request?: ChatCompletionRequest ): boolean { - // TemplateRef always routes to history — remote template cannot merge messages, - // regardless of placeholderValues. - const hasTemplateRef = configs.some(c => { - const prompt = c?.promptTemplating?.prompt; - return !!prompt && typeof prompt === 'object' && 'template_ref' in prompt; - }); - if (hasTemplateRef) { - return true; - } - // placeholderValues means the user opted into templating — do not route. if ( !!request?.placeholderValues && @@ -489,6 +482,15 @@ function shouldRouteMessagesToHistory( return false; } + // TemplateRef always routes to history — remote template cannot merge messages. + const hasTemplateRef = configs.some(c => { + const prompt = c?.promptTemplating?.prompt; + return !!prompt && typeof prompt === 'object' && 'template_ref' in prompt; + }); + if (hasTemplateRef) { + return true; + } + // No prompt configured — messages have nowhere to be merged into. return configs.every(c => !c?.promptTemplating?.prompt); } From 30286d259555580688181bf680f2cc6a3d8f4216 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Wed, 15 Jul 2026 12:58:54 +0200 Subject: [PATCH 36/49] fix: restore prompt template initialization and align unit tests with service behavior --- ...hestration-completion-post-request.test.ts | 46 +++++++++---------- .../orchestration/src/util/module-config.ts | 35 ++++++-------- 2 files changed, 35 insertions(+), 46 deletions(-) diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index df7530f29..164cd8a59 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -370,27 +370,23 @@ describe('construct completion post request', () => { messages: [userMessage, toolMessage, followUp] }); - expect(result.messages_history).toEqual([ - userMessage, - toolMessage, - followUp - ]); - expect(result.config.modules.prompt_templating.prompt).toBeUndefined(); + expect(result.messages_history).toBeUndefined(); + expect( + result.config.modules.prompt_templating.prompt.template + ).toEqual([userMessage, toolMessage, followUp]); }); it('should preserve existing messagesHistory when routing all messages', () => { const followUpUser = { role: 'user' as const, content: 'Follow up.' }; - const result = constructCompletionPostRequest(noTemplateConfig, { + const result: any = constructCompletionPostRequest(noTemplateConfig, { messages: [userMessage, toolMessage, followUpUser], messagesHistory: [assistantMessage] }); - expect(result.messages_history).toEqual([ - assistantMessage, - userMessage, - toolMessage, - followUpUser - ]); + expect(result.messages_history).toEqual([assistantMessage]); + expect( + result.config.modules.prompt_templating.prompt.template + ).toEqual([userMessage, toolMessage, followUpUser]); }); it('should route all messages to history even without tool messages', () => { @@ -398,8 +394,10 @@ describe('construct completion post request', () => { messages: [userMessage] }); - expect(result.messages_history).toEqual([userMessage]); - expect(result.config.modules.prompt_templating.prompt).toBeUndefined(); + expect(result.messages_history).toBeUndefined(); + expect( + result.config.modules.prompt_templating.prompt.template + ).toEqual([userMessage]); }); it('should preserve chronological message order in messages_history', () => { @@ -408,22 +406,22 @@ describe('construct completion post request', () => { messages: [userMessage, assistantMessage, toolMessage, followUpUser] }); - expect(result.messages_history).toEqual([ - userMessage, - assistantMessage, - toolMessage, - followUpUser - ]); - expect(result.config.modules.prompt_templating.prompt).toBeUndefined(); + expect(result.messages_history).toBeUndefined(); + expect( + result.config.modules.prompt_templating.prompt.template + ).toEqual([userMessage, assistantMessage, toolMessage, followUpUser]); }); it('should combine existing messagesHistory with routed messages', () => { - const result = constructCompletionPostRequest(noTemplateConfig, { + const result: any = constructCompletionPostRequest(noTemplateConfig, { messages: [userMessage], messagesHistory: [assistantMessage] }); - expect(result.messages_history).toEqual([assistantMessage, userMessage]); + expect(result.messages_history).toEqual([assistantMessage]); + expect( + result.config.modules.prompt_templating.prompt.template + ).toEqual([userMessage]); }); it('should not route messages when config has a static prompt template', () => { diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index b6924d40a..9549325e0 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -420,21 +420,13 @@ function buildCompletionModulesConfig( }; // prompt is not a string here as it is already parsed in `parseAndMergeTemplating` method - if (!promptTemplating.prompt) { - // No prompt configured: messages are already routed to messages_history upstream. - // Omit the prompt key entirely — it is optional per the service API. - const { prompt: _prompt, ...promptTemplatingWithoutPrompt } = - promptTemplating; - return { - prompt_templating: - promptTemplatingWithoutPrompt as PromptTemplatingModuleConfig, - ...modules - }; - } - + // If promptTemplating.prompt is not defined, initialize with an empty Template so the + // service always receives a prompt object — messages routed to messages_history upstream + // still need an empty template to be accepted by the Templating Module. const prompt: Template | TemplateRef = { ...(promptTemplating.prompt as Template | TemplateRef) }; + promptTemplating.prompt = promptTemplating.prompt || { template: [] }; if (isTemplate(prompt)) { if (!prompt.template?.length && !request?.messages?.length) { @@ -474,14 +466,6 @@ function shouldRouteMessagesToHistory( configs: OrchestrationModuleConfig[], request?: ChatCompletionRequest ): boolean { - // placeholderValues means the user opted into templating — do not route. - if ( - !!request?.placeholderValues && - Object.keys(request.placeholderValues).length > 0 - ) { - return false; - } - // TemplateRef always routes to history — remote template cannot merge messages. const hasTemplateRef = configs.some(c => { const prompt = c?.promptTemplating?.prompt; @@ -491,8 +475,15 @@ function shouldRouteMessagesToHistory( return true; } - // No prompt configured — messages have nowhere to be merged into. - return configs.every(c => !c?.promptTemplating?.prompt); + // placeholderValues means the user opted into templating — do not route. + if ( + !!request?.placeholderValues && + Object.keys(request.placeholderValues).length > 0 + ) { + return false; + } + + return false; } function isTemplate(templating: unknown): templating is Template { From 2d73010b2e2ec01903689c180f1e9424e8bf116b Mon Sep 17 00:00:00 2001 From: "sap-ai-sdk-bot[bot]" <272306433+sap-ai-sdk-bot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:02:09 +0000 Subject: [PATCH 37/49] fix: Changes from lint --- ...hestration-completion-post-request.test.ts | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index 164cd8a59..3cacc6dc7 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -371,9 +371,11 @@ describe('construct completion post request', () => { }); expect(result.messages_history).toBeUndefined(); - expect( - result.config.modules.prompt_templating.prompt.template - ).toEqual([userMessage, toolMessage, followUp]); + expect(result.config.modules.prompt_templating.prompt.template).toEqual([ + userMessage, + toolMessage, + followUp + ]); }); it('should preserve existing messagesHistory when routing all messages', () => { @@ -384,9 +386,11 @@ describe('construct completion post request', () => { }); expect(result.messages_history).toEqual([assistantMessage]); - expect( - result.config.modules.prompt_templating.prompt.template - ).toEqual([userMessage, toolMessage, followUpUser]); + expect(result.config.modules.prompt_templating.prompt.template).toEqual([ + userMessage, + toolMessage, + followUpUser + ]); }); it('should route all messages to history even without tool messages', () => { @@ -395,9 +399,9 @@ describe('construct completion post request', () => { }); expect(result.messages_history).toBeUndefined(); - expect( - result.config.modules.prompt_templating.prompt.template - ).toEqual([userMessage]); + expect(result.config.modules.prompt_templating.prompt.template).toEqual([ + userMessage + ]); }); it('should preserve chronological message order in messages_history', () => { @@ -407,9 +411,12 @@ describe('construct completion post request', () => { }); expect(result.messages_history).toBeUndefined(); - expect( - result.config.modules.prompt_templating.prompt.template - ).toEqual([userMessage, assistantMessage, toolMessage, followUpUser]); + expect(result.config.modules.prompt_templating.prompt.template).toEqual([ + userMessage, + assistantMessage, + toolMessage, + followUpUser + ]); }); it('should combine existing messagesHistory with routed messages', () => { @@ -419,9 +426,9 @@ describe('construct completion post request', () => { }); expect(result.messages_history).toEqual([assistantMessage]); - expect( - result.config.modules.prompt_templating.prompt.template - ).toEqual([userMessage]); + expect(result.config.modules.prompt_templating.prompt.template).toEqual([ + userMessage + ]); }); it('should not route messages when config has a static prompt template', () => { From 4b4b4b6417ebabb557244ec0bb6d1632f079e2a2 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Wed, 15 Jul 2026 13:10:08 +0200 Subject: [PATCH 38/49] fix: restore prompt initialization order and align tests with service behavior --- packages/orchestration/src/util/module-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 9549325e0..fe32f31db 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -423,10 +423,10 @@ function buildCompletionModulesConfig( // If promptTemplating.prompt is not defined, initialize with an empty Template so the // service always receives a prompt object — messages routed to messages_history upstream // still need an empty template to be accepted by the Templating Module. + promptTemplating.prompt = promptTemplating.prompt || { template: [] }; const prompt: Template | TemplateRef = { ...(promptTemplating.prompt as Template | TemplateRef) }; - promptTemplating.prompt = promptTemplating.prompt || { template: [] }; if (isTemplate(prompt)) { if (!prompt.template?.length && !request?.messages?.length) { From 1320ac5ab15d7e47bee922ae93277accdb0b8868 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Wed, 15 Jul 2026 13:22:38 +0200 Subject: [PATCH 39/49] fix: allow empty messages array, only throw when messages is undefined --- packages/orchestration/src/util/module-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index fe32f31db..e941ef388 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -429,7 +429,7 @@ function buildCompletionModulesConfig( }; if (isTemplate(prompt)) { - if (!prompt.template?.length && !request?.messages?.length) { + if (!prompt.template?.length && !request?.messages) { throw new Error('Either a prompt template or messages must be defined.'); } prompt.template = [ From 2de80934d638ca3105e3cd32f7419f7373872f3e Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Wed, 15 Jul 2026 13:37:52 +0200 Subject: [PATCH 40/49] fix: use splitIndex routing to bypass templating for tool results --- ...hestration-completion-post-request.test.ts | 41 ++++++++++------ .../orchestration/src/util/module-config.ts | 47 ++++++++++--------- 2 files changed, 53 insertions(+), 35 deletions(-) diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index 3cacc6dc7..17afb110f 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -357,7 +357,7 @@ describe('construct completion post request', () => { }; const userMessage = { role: 'user' as const, content: 'Summarize.' }; - // Config without a prompt — shouldRouteMessagesToHistory returns true + // Config without a prompt — messages with tool results get partial routing const noTemplateConfig: OrchestrationModuleConfig = { promptTemplating: { model: { name: 'gpt-5.4-nano' } @@ -370,10 +370,9 @@ describe('construct completion post request', () => { messages: [userMessage, toolMessage, followUp] }); - expect(result.messages_history).toBeUndefined(); + // tool at index 1 → splitIndex = 2, followUp stays in template + expect(result.messages_history).toEqual([userMessage, toolMessage]); expect(result.config.modules.prompt_templating.prompt.template).toEqual([ - userMessage, - toolMessage, followUp ]); }); @@ -385,10 +384,12 @@ describe('construct completion post request', () => { messagesHistory: [assistantMessage] }); - expect(result.messages_history).toEqual([assistantMessage]); - expect(result.config.modules.prompt_templating.prompt.template).toEqual([ + expect(result.messages_history).toEqual([ + assistantMessage, userMessage, - toolMessage, + toolMessage + ]); + expect(result.config.modules.prompt_templating.prompt.template).toEqual([ followUpUser ]); }); @@ -398,6 +399,7 @@ describe('construct completion post request', () => { messages: [userMessage] }); + // no tool messages → splitIndex = 0, all go to template expect(result.messages_history).toBeUndefined(); expect(result.config.modules.prompt_templating.prompt.template).toEqual([ userMessage @@ -410,11 +412,13 @@ describe('construct completion post request', () => { messages: [userMessage, assistantMessage, toolMessage, followUpUser] }); - expect(result.messages_history).toBeUndefined(); - expect(result.config.modules.prompt_templating.prompt.template).toEqual([ + // tool at index 2 → splitIndex = 3, followUpUser stays in template + expect(result.messages_history).toEqual([ userMessage, assistantMessage, - toolMessage, + toolMessage + ]); + expect(result.config.modules.prompt_templating.prompt.template).toEqual([ followUpUser ]); }); @@ -425,6 +429,7 @@ describe('construct completion post request', () => { messagesHistory: [assistantMessage] }); + // no tool messages → splitIndex = 0, userMessage goes to template expect(result.messages_history).toEqual([assistantMessage]); expect(result.config.modules.prompt_templating.prompt.template).toEqual([ userMessage @@ -436,10 +441,14 @@ describe('construct completion post request', () => { messages: [assistantMessage, toolMessage, userMessage] }); - expect(result.messages_history).toBeUndefined(); + // tool at index 1 → splitIndex = 2, userMessage stays in template + expect(result.messages_history).toEqual([assistantMessage, toolMessage]); + expect( + result.config.modules.prompt_templating.prompt.template + ).not.toContainEqual(toolMessage); expect( result.config.modules.prompt_templating.prompt.template - ).toContainEqual(toolMessage); + ).toContainEqual(userMessage); }); it('should not route messages when config has prompt.tools', () => { @@ -464,10 +473,14 @@ describe('construct completion post request', () => { messages: [assistantMessage, toolMessage, userMessage] }); - expect(result.messages_history).toBeUndefined(); + // tool at index 1 → splitIndex = 2, userMessage stays in template + expect(result.messages_history).toEqual([assistantMessage, toolMessage]); + expect( + result.config.modules.prompt_templating.prompt.template + ).not.toContainEqual(toolMessage); expect( result.config.modules.prompt_templating.prompt.template - ).toContainEqual(toolMessage); + ).toContainEqual(userMessage); }); }); }); diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index e941ef388..838e3b6e8 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -11,6 +11,7 @@ import { type EmbeddingRequest } from '../orchestration-types.js'; import type { + ChatMessage, CompletionPostRequest, CompletionRequestConfigurationReferenceById, CompletionRequestConfigurationReferenceByNameScenarioVersion, @@ -359,12 +360,19 @@ export function constructCompletionPostRequest( // - Config array (OrchestrationModuleConfigList) → array of ModuleConfigs for fallback behavior const configs = Array.isArray(config) ? config : [config]; - const routeToHistory = shouldRouteMessagesToHistory(configs, request); + const messages = request?.messages || []; + const splitIndex = getMessageSplitIndex(configs, messages, request); + const routeAllToHistory = splitIndex === messages.length && messages.length > 0; let moduleRequest = request; - if (routeToHistory && request) { + if (routeAllToHistory && request) { const { messages: _messages, ...rest } = request; moduleRequest = rest; + } else if (splitIndex > 0 && request) { + moduleRequest = { + ...request, + messages: messages.slice(splitIndex) + }; } /** @@ -387,10 +395,10 @@ export function constructCompletionPostRequest( : { modules: moduleConfigurations }; const messagesHistory = - routeToHistory || request?.messagesHistory?.length + splitIndex > 0 || request?.messagesHistory?.length ? [ ...(request?.messagesHistory || []), - ...(routeToHistory ? request?.messages || [] : []) + ...messages.slice(0, splitIndex) ] : undefined; @@ -448,42 +456,39 @@ function buildCompletionModulesConfig( } /** - * Determines whether all messages should be routed to messages_history, - * bypassing prompt templating entirely. - * - * Routes to history when: - * - no `prompt` is configured (messages have nowhere to be merged) - * - a TemplateRef is used (remote template, messages cannot be merged in). + * Returns the index at which to split request.messages. + * Messages before the index go to messages_history (bypassing prompt templating). + * Messages from the index onward go to prompt.template (going through templating). * - * Does NOT route to history when: - * - a Template is set (messages are merged into prompt.template) - * - placeholder_values are provided (user has opted into templating). + * Full routing (splitIndex = messages.length): TemplateRef configs — remote template, cannot merge messages in. + * Partial routing (splitIndex = lastToolIndex + 1): tool results may contain {{?...}} syntax from external systems. + * No routing (splitIndex = 0): placeholder values are set or no tool messages present. * @param configs - The orchestration module configurations. + * @param messages - The messages from the request. * @param request - The optional chat completion request. - * @returns True if all messages should be routed to messages_history. + * @returns The split index. */ -function shouldRouteMessagesToHistory( +function getMessageSplitIndex( configs: OrchestrationModuleConfig[], + messages: ChatMessage[], request?: ChatCompletionRequest -): boolean { - // TemplateRef always routes to history — remote template cannot merge messages. +): number { const hasTemplateRef = configs.some(c => { const prompt = c?.promptTemplating?.prompt; return !!prompt && typeof prompt === 'object' && 'template_ref' in prompt; }); if (hasTemplateRef) { - return true; + return messages.length; } - // placeholderValues means the user opted into templating — do not route. if ( !!request?.placeholderValues && Object.keys(request.placeholderValues).length > 0 ) { - return false; + return 0; } - return false; + return messages.findLastIndex(msg => msg.role === 'tool') + 1; } function isTemplate(templating: unknown): templating is Template { From 96c2af20090d5eb6f91fbbb26b27f4c20045a138 Mon Sep 17 00:00:00 2001 From: "sap-ai-sdk-bot[bot]" <272306433+sap-ai-sdk-bot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:39:03 +0000 Subject: [PATCH 41/49] fix: Changes from lint --- packages/orchestration/src/util/module-config.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 838e3b6e8..8ebfa57de 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -362,7 +362,8 @@ export function constructCompletionPostRequest( const configs = Array.isArray(config) ? config : [config]; const messages = request?.messages || []; const splitIndex = getMessageSplitIndex(configs, messages, request); - const routeAllToHistory = splitIndex === messages.length && messages.length > 0; + const routeAllToHistory = + splitIndex === messages.length && messages.length > 0; let moduleRequest = request; if (routeAllToHistory && request) { @@ -396,10 +397,7 @@ export function constructCompletionPostRequest( const messagesHistory = splitIndex > 0 || request?.messagesHistory?.length - ? [ - ...(request?.messagesHistory || []), - ...messages.slice(0, splitIndex) - ] + ? [...(request?.messagesHistory || []), ...messages.slice(0, splitIndex)] : undefined; return { From 2f5d648ada9ce8d6bb269fed4784311b155a2aa2 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Wed, 15 Jul 2026 13:47:33 +0200 Subject: [PATCH 42/49] fix: pass empty messages array instead of removing when all messages are routed to history --- packages/orchestration/src/util/module-config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 8ebfa57de..c89a1686a 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -367,8 +367,7 @@ export function constructCompletionPostRequest( let moduleRequest = request; if (routeAllToHistory && request) { - const { messages: _messages, ...rest } = request; - moduleRequest = rest; + moduleRequest = { ...request, messages: [] }; } else if (splitIndex > 0 && request) { moduleRequest = { ...request, From 831828a207ebc6e7f95869b5c439e36916a1522f Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Wed, 15 Jul 2026 14:11:50 +0200 Subject: [PATCH 43/49] fix: omit empty prompt.template when all messages are routed to history --- packages/orchestration/src/util/module-config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index c89a1686a..831cd39bc 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -437,10 +437,15 @@ function buildCompletionModulesConfig( if (!prompt.template?.length && !request?.messages) { throw new Error('Either a prompt template or messages must be defined.'); } - prompt.template = [ + const mergedTemplate = [ ...(prompt.template || []), ...(request?.messages || []) ]; + if (mergedTemplate.length) { + prompt.template = mergedTemplate; + } else { + (prompt as Record).template = undefined; + } } return { From 826f5f65a5141ce09a8cea58f982660499cc6385 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Wed, 15 Jul 2026 14:23:31 +0200 Subject: [PATCH 44/49] fix: only route to history when there are messages after the last tool message --- packages/orchestration/src/util/module-config.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 831cd39bc..1d6eaa8dd 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -490,7 +490,13 @@ function getMessageSplitIndex( return 0; } - return messages.findLastIndex(msg => msg.role === 'tool') + 1; + const lastToolIndex = messages.findLastIndex(msg => msg.role === 'tool'); + // Only route if there are messages after the last tool message — + // the service requires at least one message in prompt.template. + if (lastToolIndex >= 0 && lastToolIndex < messages.length - 1) { + return lastToolIndex + 1; + } + return 0; } function isTemplate(templating: unknown): templating is Template { From 76a54cd46879773f9b2f7993c9c17d94edb706f6 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Thu, 16 Jul 2026 15:13:22 +0200 Subject: [PATCH 45/49] fix: replace splitIndex with shouldRouteMessagesToHistory and add tool message detection --- .changeset/yummy-ducks-run.md | 5 +- ...hestration-completion-post-request.test.ts | 65 ++++++------ .../orchestration/src/orchestration-types.ts | 11 ++- .../orchestration/src/util/module-config.ts | 98 +++++++++---------- tests/e2e-tests/src/orchestration.test.ts | 4 +- 5 files changed, 88 insertions(+), 95 deletions(-) diff --git a/.changeset/yummy-ducks-run.md b/.changeset/yummy-ducks-run.md index 5966f6790..199406e34 100644 --- a/.changeset/yummy-ducks-run.md +++ b/.changeset/yummy-ducks-run.md @@ -2,6 +2,5 @@ '@sap-ai-sdk/orchestration': patch --- -[Fix] Automatically route all `messages` to `messages_history` to bypass prompt templating when no `prompt` is configured or a `TemplateRef` is used. -Routing is skipped when a `Template` is configured or `placeholderValues` are provided. - +[Fix] Automatically route `messages` to `messages_history` to bypass prompt templating when tool results are present (preventing `{{?...}}` syntax in tool content from being interpreted as template placeholders), a `TemplateRef` is used, or no `prompt` is configured. +Routing is skipped when `placeholderValues` are provided. diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index 17afb110f..f6b8e6e51 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -357,24 +357,21 @@ describe('construct completion post request', () => { }; const userMessage = { role: 'user' as const, content: 'Summarize.' }; - // Config without a prompt — messages with tool results get partial routing + // Config without a prompt — tool messages trigger full routing to messages_history const noTemplateConfig: OrchestrationModuleConfig = { promptTemplating: { model: { name: 'gpt-5.4-nano' } } }; - it('should route all messages to messages_history when no prompt is configured', () => { + it('should route all messages to messages_history when tool message is present', () => { const followUp = { role: 'user' as const, content: 'Follow up.' }; const result: any = constructCompletionPostRequest(noTemplateConfig, { messages: [userMessage, toolMessage, followUp] }); - // tool at index 1 → splitIndex = 2, followUp stays in template - expect(result.messages_history).toEqual([userMessage, toolMessage]); - expect(result.config.modules.prompt_templating.prompt.template).toEqual([ - followUp - ]); + expect(result.messages_history).toEqual([userMessage, toolMessage, followUp]); + expect(result.config.modules.prompt_templating.prompt).toBeUndefined(); }); it('should preserve existing messagesHistory when routing all messages', () => { @@ -387,38 +384,32 @@ describe('construct completion post request', () => { expect(result.messages_history).toEqual([ assistantMessage, userMessage, - toolMessage - ]); - expect(result.config.modules.prompt_templating.prompt.template).toEqual([ + toolMessage, followUpUser ]); }); - it('should route all messages to history even without tool messages', () => { + it('should not route messages when no tool messages are present', () => { const result: any = constructCompletionPostRequest(noTemplateConfig, { messages: [userMessage] }); - // no tool messages → splitIndex = 0, all go to template + // no tool messages → no routing, userMessage merged into prompt.template expect(result.messages_history).toBeUndefined(); expect(result.config.modules.prompt_templating.prompt.template).toEqual([ userMessage ]); }); - - it('should preserve chronological message order in messages_history', () => { + it('should preserve chronological message order when routing to messages_history', () => { const followUpUser = { role: 'user' as const, content: 'Follow up.' }; const result: any = constructCompletionPostRequest(noTemplateConfig, { messages: [userMessage, assistantMessage, toolMessage, followUpUser] }); - // tool at index 2 → splitIndex = 3, followUpUser stays in template expect(result.messages_history).toEqual([ userMessage, assistantMessage, - toolMessage - ]); - expect(result.config.modules.prompt_templating.prompt.template).toEqual([ + toolMessage, followUpUser ]); }); @@ -429,29 +420,30 @@ describe('construct completion post request', () => { messagesHistory: [assistantMessage] }); - // no tool messages → splitIndex = 0, userMessage goes to template + // no tool messages → no routing, userMessage merged into prompt.template expect(result.messages_history).toEqual([assistantMessage]); expect(result.config.modules.prompt_templating.prompt.template).toEqual([ userMessage ]); }); - it('should not route messages when config has a static prompt template', () => { + it('should route all messages when tool message present with static prompt template', () => { const result: any = constructCompletionPostRequest(defaultConfig, { messages: [assistantMessage, toolMessage, userMessage] }); - // tool at index 1 → splitIndex = 2, userMessage stays in template - expect(result.messages_history).toEqual([assistantMessage, toolMessage]); - expect( - result.config.modules.prompt_templating.prompt.template - ).not.toContainEqual(toolMessage); - expect( - result.config.modules.prompt_templating.prompt.template - ).toContainEqual(userMessage); + expect(result.messages_history).toEqual([ + assistantMessage, + toolMessage, + userMessage + ]); + // static template remains, messages were removed (routed to history) + expect(result.config.modules.prompt_templating.prompt.template).toEqual([ + { role: 'user', content: 'Hi' } + ]); }); - it('should not route messages when config has prompt.tools', () => { + it('should route all messages when tool message present with prompt.tools config', () => { const toolsConfig: OrchestrationModuleConfig = { promptTemplating: { model: { name: 'gpt-5.4-nano' }, @@ -473,14 +465,13 @@ describe('construct completion post request', () => { messages: [assistantMessage, toolMessage, userMessage] }); - // tool at index 1 → splitIndex = 2, userMessage stays in template - expect(result.messages_history).toEqual([assistantMessage, toolMessage]); - expect( - result.config.modules.prompt_templating.prompt.template - ).not.toContainEqual(toolMessage); - expect( - result.config.modules.prompt_templating.prompt.template - ).toContainEqual(userMessage); + expect(result.messages_history).toEqual([ + assistantMessage, + toolMessage, + userMessage + ]); + // tools config: messages removed, prompt.template omitted when empty + expect(result.config.modules.prompt_templating.prompt.template).toBeUndefined(); }); }); }); diff --git a/packages/orchestration/src/orchestration-types.ts b/packages/orchestration/src/orchestration-types.ts index cb7928039..e142bc170 100644 --- a/packages/orchestration/src/orchestration-types.ts +++ b/packages/orchestration/src/orchestration-types.ts @@ -46,10 +46,13 @@ export interface ChatCompletionRequest { /** * New chat messages, including template messages. - * All messages are automatically routed to `messages_history` when no `prompt` is configured - * or a `TemplateRef` is used, bypassing prompt templating entirely. - * When a `Template` is configured, messages are merged into `prompt.template` as usual. - * Routing is skipped when `placeholderValues` are provided. + * Messages are automatically routed to `messages_history` (bypassing prompt templating) when: + * - tool results are present (`role: 'tool'`) — prevents `{{?...}}` syntax in tool content + * from being misinterpreted as template placeholders + * - a `TemplateRef` is used (remote template, cannot merge messages in) + * - no `prompt` is configured + * + * Routing is skipped when `placeholderValues` are provided (user has opted into templating). * To verify the final message order sent to the LLM, use * `response.getIntermediateResults().templating`. * @example diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 1d6eaa8dd..6050e64a1 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -11,7 +11,6 @@ import { type EmbeddingRequest } from '../orchestration-types.js'; import type { - ChatMessage, CompletionPostRequest, CompletionRequestConfigurationReferenceById, CompletionRequestConfigurationReferenceByNameScenarioVersion, @@ -360,20 +359,12 @@ export function constructCompletionPostRequest( // - Config array (OrchestrationModuleConfigList) → array of ModuleConfigs for fallback behavior const configs = Array.isArray(config) ? config : [config]; - const messages = request?.messages || []; - const splitIndex = getMessageSplitIndex(configs, messages, request); - const routeAllToHistory = - splitIndex === messages.length && messages.length > 0; - - let moduleRequest = request; - if (routeAllToHistory && request) { - moduleRequest = { ...request, messages: [] }; - } else if (splitIndex > 0 && request) { - moduleRequest = { - ...request, - messages: messages.slice(splitIndex) - }; - } + const routeToHistory = shouldRouteMessagesToHistory(configs, request); + + const moduleRequest = + routeToHistory && request + ? { ...request, messages: undefined } + : request; /** * Module configurations for the orchestration request. @@ -394,10 +385,12 @@ export function constructCompletionPostRequest( ) : { modules: moduleConfigurations }; + // Only append messages when routing is active to avoid duplicating messages + // already present in messagesHistory. const messagesHistory = - splitIndex > 0 || request?.messagesHistory?.length - ? [...(request?.messagesHistory || []), ...messages.slice(0, splitIndex)] - : undefined; + routeToHistory && request?.messages?.length + ? [...(request.messagesHistory || []), ...request.messages] + : request?.messagesHistory; return { config: configWithStream, @@ -425,16 +418,24 @@ function buildCompletionModulesConfig( }; // prompt is not a string here as it is already parsed in `parseAndMergeTemplating` method - // If promptTemplating.prompt is not defined, initialize with an empty Template so the - // service always receives a prompt object — messages routed to messages_history upstream - // still need an empty template to be accepted by the Templating Module. + // If promptTemplating.prompt is not defined and messages were routed upstream, + // omit the prompt key entirely so the service receives only messages_history. + if (!promptTemplating.prompt && request?.messages === undefined) { + const { prompt: _prompt, ...rest } = promptTemplating; + return { + prompt_templating: rest as PromptTemplatingModuleConfig, + ...modules + }; + } + + // If promptTemplating.prompt is not defined, we initialize it with an empty Template object promptTemplating.prompt = promptTemplating.prompt || { template: [] }; - const prompt: Template | TemplateRef = { + const prompt = { ...(promptTemplating.prompt as Template | TemplateRef) }; if (isTemplate(prompt)) { - if (!prompt.template?.length && !request?.messages) { + if (!prompt.template?.length && !request) { throw new Error('Either a prompt template or messages must be defined.'); } const mergedTemplate = [ @@ -444,7 +445,7 @@ function buildCompletionModulesConfig( if (mergedTemplate.length) { prompt.template = mergedTemplate; } else { - (prompt as Record).template = undefined; + delete (prompt as Partial).template; } } @@ -458,45 +459,34 @@ function buildCompletionModulesConfig( } /** - * Returns the index at which to split request.messages. - * Messages before the index go to messages_history (bypassing prompt templating). - * Messages from the index onward go to prompt.template (going through templating). + * Determines whether all messages should be routed to messages_history, + * bypassing prompt templating entirely. + * + * Routes when: + * - a TemplateRef is used (remote template, cannot merge messages in) + * - messages contain tool results (content may include {{?...}} syntax from external systems). * - * Full routing (splitIndex = messages.length): TemplateRef configs — remote template, cannot merge messages in. - * Partial routing (splitIndex = lastToolIndex + 1): tool results may contain {{?...}} syntax from external systems. - * No routing (splitIndex = 0): placeholder values are set or no tool messages present. + * Does NOT route when placeholder values are provided (user opted into templating). * @param configs - The orchestration module configurations. - * @param messages - The messages from the request. * @param request - The optional chat completion request. - * @returns The split index. + * @returns True if messages should be routed to messages_history. */ -function getMessageSplitIndex( +function shouldRouteMessagesToHistory( configs: OrchestrationModuleConfig[], - messages: ChatMessage[], request?: ChatCompletionRequest -): number { - const hasTemplateRef = configs.some(c => { - const prompt = c?.promptTemplating?.prompt; - return !!prompt && typeof prompt === 'object' && 'template_ref' in prompt; - }); - if (hasTemplateRef) { - return messages.length; - } - +): boolean { if ( !!request?.placeholderValues && Object.keys(request.placeholderValues).length > 0 ) { - return 0; + return false; } - const lastToolIndex = messages.findLastIndex(msg => msg.role === 'tool'); - // Only route if there are messages after the last tool message — - // the service requires at least one message in prompt.template. - if (lastToolIndex >= 0 && lastToolIndex < messages.length - 1) { - return lastToolIndex + 1; + if (configs.some(c => isTemplateRef(c?.promptTemplating?.prompt || {}))) { + return true; } - return 0; + + return !!request?.messages?.some(m => m.role === 'tool'); } function isTemplate(templating: unknown): templating is Template { @@ -507,6 +497,14 @@ function isTemplate(templating: unknown): templating is Template { ); } +function isTemplateRef(templating: unknown): templating is TemplateRef { + return ( + !!templating && + typeof templating === 'object' && + 'template_ref' in templating + ); +} + /** * Constructs an embedding post request from the given configuration and request. * @internal diff --git a/tests/e2e-tests/src/orchestration.test.ts b/tests/e2e-tests/src/orchestration.test.ts index f4b57f411..4229f08a2 100644 --- a/tests/e2e-tests/src/orchestration.test.ts +++ b/tests/e2e-tests/src/orchestration.test.ts @@ -343,7 +343,9 @@ describe('orchestration', () => { const templating = response.getIntermediateResults().templating; const roles = templating!.map(m => m.role); - expect(roles).toEqual(['assistant', 'tool', 'user']); + expect(roles).toContain('tool'); + expect(roles).toContain('user'); + expect(roles.lastIndexOf('tool')).toBeLessThan(roles.lastIndexOf('user')); }); it('should apply masking to tool results automatically routed to messages_history', async () => { From a2a10e0abf89b421e1e95242ed4aa63fcc20e805 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Thu, 16 Jul 2026 15:37:07 +0200 Subject: [PATCH 46/49] fix: fix JSDoc indentation and skip routing when prompt.tools is present --- ...rchestration-completion-post-request.test.ts | 14 +++++++++----- .../orchestration/src/orchestration-types.ts | 13 ++++--------- .../orchestration/src/util/module-config.ts | 17 +++++++++++++---- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index f6b8e6e51..5608cb556 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -370,7 +370,11 @@ describe('construct completion post request', () => { messages: [userMessage, toolMessage, followUp] }); - expect(result.messages_history).toEqual([userMessage, toolMessage, followUp]); + expect(result.messages_history).toEqual([ + userMessage, + toolMessage, + followUp + ]); expect(result.config.modules.prompt_templating.prompt).toBeUndefined(); }); @@ -443,7 +447,7 @@ describe('construct completion post request', () => { ]); }); - it('should route all messages when tool message present with prompt.tools config', () => { + it('should not route messages when config has prompt.tools (service requires template alongside tools)', () => { const toolsConfig: OrchestrationModuleConfig = { promptTemplating: { model: { name: 'gpt-5.4-nano' }, @@ -465,13 +469,13 @@ describe('construct completion post request', () => { messages: [assistantMessage, toolMessage, userMessage] }); - expect(result.messages_history).toEqual([ + // prompt.tools present → routing skipped, messages merged into template + expect(result.messages_history).toBeUndefined(); + expect(result.config.modules.prompt_templating.prompt.template).toEqual([ assistantMessage, toolMessage, userMessage ]); - // tools config: messages removed, prompt.template omitted when empty - expect(result.config.modules.prompt_templating.prompt.template).toBeUndefined(); }); }); }); diff --git a/packages/orchestration/src/orchestration-types.ts b/packages/orchestration/src/orchestration-types.ts index e142bc170..4f044f22e 100644 --- a/packages/orchestration/src/orchestration-types.ts +++ b/packages/orchestration/src/orchestration-types.ts @@ -46,15 +46,10 @@ export interface ChatCompletionRequest { /** * New chat messages, including template messages. - * Messages are automatically routed to `messages_history` (bypassing prompt templating) when: - * - tool results are present (`role: 'tool'`) — prevents `{{?...}}` syntax in tool content - * from being misinterpreted as template placeholders - * - a `TemplateRef` is used (remote template, cannot merge messages in) - * - no `prompt` is configured - * - * Routing is skipped when `placeholderValues` are provided (user has opted into templating). - * To verify the final message order sent to the LLM, use - * `response.getIntermediateResults().templating`. + * Messages are automatically routed to `messages_history` (bypassing prompt templating) when tool results are present (`role: 'tool'`), + * a `TemplateRef` is used, or no `prompt` is configured. + * Routing is skipped when `placeholderValues` are provided. + * To verify the final message order sent to the LLM, use `response.getIntermediateResults().templating`. * @example * messages: [ * { diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 6050e64a1..4ea6ade92 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -362,9 +362,7 @@ export function constructCompletionPostRequest( const routeToHistory = shouldRouteMessagesToHistory(configs, request); const moduleRequest = - routeToHistory && request - ? { ...request, messages: undefined } - : request; + routeToHistory && request ? { ...request, messages: undefined } : request; /** * Module configurations for the orchestration request. @@ -445,7 +443,7 @@ function buildCompletionModulesConfig( if (mergedTemplate.length) { prompt.template = mergedTemplate; } else { - delete (prompt as Partial).template; + prompt.template = []; } } @@ -486,6 +484,17 @@ function shouldRouteMessagesToHistory( return true; } + // Skip routing when any config has prompt.tools — the service requires template + // to be present alongside tools, so routing would strip messages and break the request. + if ( + configs.some(c => { + const prompt = c?.promptTemplating?.prompt; + return isTemplate(prompt) && !!(prompt as { tools?: unknown }).tools; + }) + ) { + return false; + } + return !!request?.messages?.some(m => m.role === 'tool'); } From cce280bebf3fb4a1256d03ebe3e1607eff1609a8 Mon Sep 17 00:00:00 2001 From: Injun Park Date: Fri, 17 Jul 2026 14:19:25 +0200 Subject: [PATCH 47/49] Apply suggestions from code review Co-authored-by: David Knaack --- packages/orchestration/src/util/module-config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 4ea6ade92..448b0d18d 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -489,13 +489,13 @@ function shouldRouteMessagesToHistory( if ( configs.some(c => { const prompt = c?.promptTemplating?.prompt; - return isTemplate(prompt) && !!(prompt as { tools?: unknown }).tools; + return isTemplate(prompt) || !!(prompt as { tools?: unknown }).tools; }) ) { return false; } - return !!request?.messages?.some(m => m.role === 'tool'); + return true; } function isTemplate(templating: unknown): templating is Template { From 73d0fdd9aa321e2b5dff3cd803270da1c6e613d2 Mon Sep 17 00:00:00 2001 From: InjunPark-sap Date: Fri, 17 Jul 2026 14:37:23 +0200 Subject: [PATCH 48/49] apply feedbacks --- .changeset/yummy-ducks-run.md | 4 +-- ...hestration-completion-post-request.test.ts | 28 ++++++++----------- .../orchestration/src/util/module-config.ts | 15 +++++----- tsconfig.base.json | 4 +-- 4 files changed, 22 insertions(+), 29 deletions(-) diff --git a/.changeset/yummy-ducks-run.md b/.changeset/yummy-ducks-run.md index 199406e34..ab36c755f 100644 --- a/.changeset/yummy-ducks-run.md +++ b/.changeset/yummy-ducks-run.md @@ -2,5 +2,5 @@ '@sap-ai-sdk/orchestration': patch --- -[Fix] Automatically route `messages` to `messages_history` to bypass prompt templating when tool results are present (preventing `{{?...}}` syntax in tool content from being interpreted as template placeholders), a `TemplateRef` is used, or no `prompt` is configured. -Routing is skipped when `placeholderValues` are provided. +[Fix] Automatically route `messages` to `messages_history` to bypass prompt templating when no `prompt` is configured or a `TemplateRef` is used. +Routing is skipped when `placeholderValues` are provided or a prompt with `template` or `tools` is set. diff --git a/packages/orchestration/src/orchestration-completion-post-request.test.ts b/packages/orchestration/src/orchestration-completion-post-request.test.ts index 5608cb556..5b3a8f328 100644 --- a/packages/orchestration/src/orchestration-completion-post-request.test.ts +++ b/packages/orchestration/src/orchestration-completion-post-request.test.ts @@ -393,16 +393,14 @@ describe('construct completion post request', () => { ]); }); - it('should not route messages when no tool messages are present', () => { + it('should route messages when no prompt is configured (even without tool messages)', () => { const result: any = constructCompletionPostRequest(noTemplateConfig, { messages: [userMessage] }); - // no tool messages → no routing, userMessage merged into prompt.template - expect(result.messages_history).toBeUndefined(); - expect(result.config.modules.prompt_templating.prompt.template).toEqual([ - userMessage - ]); + // no prompt → always route to messages_history + expect(result.messages_history).toEqual([userMessage]); + expect(result.config.modules.prompt_templating.prompt).toBeUndefined(); }); it('should preserve chronological message order when routing to messages_history', () => { const followUpUser = { role: 'user' as const, content: 'Follow up.' }; @@ -424,27 +422,23 @@ describe('construct completion post request', () => { messagesHistory: [assistantMessage] }); - // no tool messages → no routing, userMessage merged into prompt.template - expect(result.messages_history).toEqual([assistantMessage]); - expect(result.config.modules.prompt_templating.prompt.template).toEqual([ - userMessage - ]); + // no prompt → always route, prepended to existing messagesHistory + expect(result.messages_history).toEqual([assistantMessage, userMessage]); }); - it('should route all messages when tool message present with static prompt template', () => { + it('should not route messages when config has a static prompt template', () => { const result: any = constructCompletionPostRequest(defaultConfig, { messages: [assistantMessage, toolMessage, userMessage] }); - expect(result.messages_history).toEqual([ + // prompt.template present → no routing, messages merged into template + expect(result.messages_history).toBeUndefined(); + expect(result.config.modules.prompt_templating.prompt.template).toEqual([ + { role: 'user', content: 'Hi' }, assistantMessage, toolMessage, userMessage ]); - // static template remains, messages were removed (routed to history) - expect(result.config.modules.prompt_templating.prompt.template).toEqual([ - { role: 'user', content: 'Hi' } - ]); }); it('should not route messages when config has prompt.tools (service requires template alongside tools)', () => { diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 448b0d18d..0cb96afaf 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -473,6 +473,10 @@ function shouldRouteMessagesToHistory( configs: OrchestrationModuleConfig[], request?: ChatCompletionRequest ): boolean { + if (configs.some(c => isTemplateRef(c?.promptTemplating?.prompt || {}))) { + return true; + } + if ( !!request?.placeholderValues && Object.keys(request.placeholderValues).length > 0 @@ -480,16 +484,13 @@ function shouldRouteMessagesToHistory( return false; } - if (configs.some(c => isTemplateRef(c?.promptTemplating?.prompt || {}))) { - return true; - } - - // Skip routing when any config has prompt.tools — the service requires template - // to be present alongside tools, so routing would strip messages and break the request. + // Skip routing when any config has a prompt with template or tools set. + // The service requires template to be present alongside tools, + // so routing would strip messages and break the request. if ( configs.some(c => { const prompt = c?.promptTemplating?.prompt; - return isTemplate(prompt) || !!(prompt as { tools?: unknown }).tools; + return !!prompt && (isTemplate(prompt) || !!(prompt as { tools?: unknown }).tools); }) ) { return false; diff --git a/tsconfig.base.json b/tsconfig.base.json index 1f5177363..b0d51ad4e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,7 +1,6 @@ { "compilerOptions": { - "target": "ES2022", - "lib": ["es2023"], + "target": "ES2024", "module": "Node16", "declaration": true, "declarationMap": true, @@ -11,7 +10,6 @@ "strict": true, "skipLibCheck": true, "isolatedModules": true, - "lib": ["es2024"], "types": ["node"] } } From 8de127f68c7437251700265d452b91c94c1ddbfe Mon Sep 17 00:00:00 2001 From: "sap-ai-sdk-bot[bot]" <272306433+sap-ai-sdk-bot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:38:32 +0000 Subject: [PATCH 49/49] fix: Changes from generation --- packages/orchestration/src/util/module-config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/orchestration/src/util/module-config.ts b/packages/orchestration/src/util/module-config.ts index 0cb96afaf..1c923c3aa 100644 --- a/packages/orchestration/src/util/module-config.ts +++ b/packages/orchestration/src/util/module-config.ts @@ -490,7 +490,10 @@ function shouldRouteMessagesToHistory( if ( configs.some(c => { const prompt = c?.promptTemplating?.prompt; - return !!prompt && (isTemplate(prompt) || !!(prompt as { tools?: unknown }).tools); + return ( + !!prompt && + (isTemplate(prompt) || !!(prompt as { tools?: unknown }).tools) + ); }) ) { return false;