refactor(llm-pi-ai): delegate system message placement to pi-ai

This commit is contained in:
Yixiang Tu committed 2026-10-08 16:38:58 +08:00
1 parent b133fb9286
commit 46164dfb2b
10 files changed
+68 -67

No files matched your search

@@ -15,8 +15,8 @@
en: 4921a060c657b790
zh: 2f9632469c6f0b60
/agent-note-provider-routed-llm-adapters-and-a-generic-pi-ai-backend/decision/explicit-pi-ai-provider-profiles:
en: e24610c474a94bc4
zh: 96a89035cb8eabf9
en: 1346315312eb35e2
zh: 3555f91c3afb49f8
/agent-note-provider-routed-llm-adapters-and-a-generic-pi-ai-backend/decision/recorded-assistant-route-and-replay-state:
en: ed60d078eacc67df
zh: a22631faf93deee2
@@ -36,6 +36,8 @@ The adapter calls pi-ai's `streamSimple()` so each catalog model chooses its reg
pi-ai's common stream options do not expose stop sequences. `dsh-llm-pi-ai` rejects a defined Harness `stop` option with `UNSUPPORTED_OPTION` rather than silently ignoring it or growing a second provider-specific payload implementation. `dsh-llm-deepseek` continues to support `stop` through its native request serializer.
pi-ai owns protocol-specific system-message placement and the concatenation fallback for models without in-history system support. Harness preserves later system messages in transcript order and reports catalog capabilities to prompt admission and shared tool-history projection. Duplicating pi-ai's fallback or applying Anthropic's placement rules to every protocol would create a second serialization policy that can drift when pi-ai changes. Capability reporting remains a Harness responsibility because the loop needs it before serialization.
### Recorded assistant route and replay state
Assistant messages carry the request's `provider` and `model`, plus an optional JSON-serializable adapter replay state. A successful `assistant/message` session event records those fields and `deriveMessages()` returns them with the assistant message. User, system, context, and tool-result messages carry no assistant route fields. The provider/model fields are authoritative loop data; an adapter owns only its opaque replay-state payload.
@@ -36,6 +36,8 @@ Status: implemented
pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定义,`dsh-llm-pi-ai` 会以 `UNSUPPORTED_OPTION` 拒绝请求,不会静默忽略,也不会增加第二套提供方专用 payload 实现。`dsh-llm-deepseek` 继续通过原生请求序列化器支持 `stop`。
pi-ai 负责各协议的系统消息位置,以及不支持历史内系统消息的模型所需的拼接回退。Harness 按对话记录顺序保留后续系统消息,并向提示词准入和共享工具历史投影报告目录能力。重复实现 pi-ai 的回退,或将 Anthropic 的位置规则应用于所有协议,会形成第二套序列化策略,并可能随 pi-ai 的变化而偏离。能力报告仍由 Harness 负责,因为循环在序列化之前就需要这些信息。
### 已记录的助手路由与回放状态
助手消息携带请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些字段,`deriveMessages()` 返回助手消息时也会包含它们。用户、系统、上下文与工具结果消息不携带助手路由字段。提供方/模型字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。
+2 -2
View File
@@ -66,8 +66,8 @@
en: e7348db3f7115960
zh: 5fd5fb09d7d09982
/deepseek-ai-dsh-llm-pi-ai/model-experience/provider-request-through-pi-ai/what-the-model-sees:
en: d4c7ef9485935e2d
zh: 3caff518281b3c47
en: 203535e4c439eb08
zh: d51d696f122691cb
/deepseek-ai-dsh-llm-pi-ai/model-experience/provider-request-through-pi-ai/token-effect:
en: 820221e8ae37aa61
zh: 7e6ff4db10067fc0
+1 -1
View File
@@ -185,7 +185,7 @@ Read these pages when the package-level contract is not enough. They move from t
#### What the model sees
The selected catalog model receives system-message text, conversation history, tools, and supported sampling fields. A model with mid-conversation system support retains each system update after the user/tool-result turn and before the next assistant; other models receive all system messages concatenated in history order at the beginning. One-shot `GenerateOptions.system` precedes the initial history prompt. Repeated system messages remain repeated; empty system messages are skipped without discarding earlier text. Tool-update capabilities select the shared historical declaration projection; pi-ai receives initially active tools and grouped developer updates carrying additions and supported removals. When every projected declaration of an Anthropic request with tool updates is deferred, the initially active tools are one placeholder, `DeferredToolPlaceholder`, whose description tells the model never to call it; a call reaches the ordinary unknown-tool result. Anthropic receives later tool definitions inline in the update message. Replay preserves tool-call namespaces for dynamically loaded Responses tools. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence selected by a logged image-offload decision keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content.
The selected catalog model receives system-message text, conversation history, tools, and supported sampling fields. The adapter preserves later system messages in history order; pi-ai owns protocol-specific placement and concatenates them at the beginning for models without mid-conversation system support. One-shot `GenerateOptions.system` precedes the initial history prompt. Repeated system messages remain repeated; empty system messages are skipped without discarding earlier text. Tool-update capabilities select the shared historical declaration projection; pi-ai receives initially active tools and grouped developer updates carrying additions and supported removals. When every projected declaration of an Anthropic request with tool updates is deferred, the initially active tools are one placeholder, `DeferredToolPlaceholder`, whose description tells the model never to call it; a call reaches the ordinary unknown-tool result. Anthropic receives later tool definitions inline in the update message. Replay preserves tool-call namespaces for dynamically loaded Responses tools. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence selected by a logged image-offload decision keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content.
#### Token effect
+1 -1
View File
@@ -185,7 +185,7 @@ Config 更新严格验证发生变化的 provider。初始加载将已存储的
#### 模型看到什么
所选目录模型会收到系统消息文本、对话历史、工具以及支持的采样字段。支持对话中系统消息的模型会在用户或工具结果轮次之后、下一条助手消息之前保留每条系统更新;其他模型在开头收到按历史顺序拼接的所有系统消息。单次调用的 `GenerateOptions.system` 放在初始历史提示词之前。重复的系统消息仍会重复发送;空系统消息会被跳过,不会丢弃之前的文本。工具更新能力选择共享的历史声明投影;pi-ai 接收初始可用工具,以及按开发者消息分组的新增和受支持的移除更新。当包含工具更新的 Anthropic 请求中所有投影后的声明均为延迟加载时,初始可用工具为一个占位工具 `DeferredToolPlaceholder`,其描述要求模型永不调用它;若被调用,会得到普通的未知工具结果。Anthropic 在更新消息中以内联方式接收后续工具定义。回放会保留工具调用命名空间,以支持动态加载的 Responses 工具。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。日志中的图片省略决策选中的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试步骤。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。
所选目录模型会收到系统消息文本、对话历史、工具以及支持的采样字段。适配器按历史顺序保留后续系统消息;pi-ai 负责各协议的消息位置,并为不支持对话中系统消息的模型将它们拼接到开头。单次调用的 `GenerateOptions.system` 放在初始历史提示词之前。重复的系统消息仍会重复发送;空系统消息会被跳过,不会丢弃之前的文本。工具更新能力选择共享的历史声明投影;pi-ai 接收初始可用工具,以及按开发者消息分组的新增和受支持的移除更新。当包含工具更新的 Anthropic 请求中所有投影后的声明均为延迟加载时,初始可用工具为一个占位工具 `DeferredToolPlaceholder`,其描述要求模型永不调用它;若被调用,会得到普通的未知工具结果。Anthropic 在更新消息中以内联方式接收后续工具定义。回放会保留工具调用命名空间,以支持动态加载的 Responses 工具。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。日志中的图片省略决策选中的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试步骤。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。
#### Token 影响
+2 -2
View File
@@ -389,7 +389,7 @@ export class PiAiAdapter extends LlmAdapter {
this.config.onReplayDegrade?.({ provider: options.provider, model: options.model, reason })
}
const context = attachments === undefined
? toPiContext(request, undefined, onReplayDegrade, updates.systemPromptUpdate)
? toPiContext(request, undefined, onReplayDegrade)
: await toPiContext({ ...request, signal: watchdog.signal }, {
attachments,
resolveImageAccess: ref => this.config.resolveImageAccess?.(attachments, ref),
@@ -398,7 +398,7 @@ export class PiAiAdapter extends LlmAdapter {
maxPixels: profile.requestImagePixelBudget,
maxBytes: profile.requestImageMaxBytes,
},
}, onReplayDegrade, updates.systemPromptUpdate)
}, onReplayDegrade)
const events = snapshot.models.streamSimple(model, context, {
...profileOptions(profile, reasoning, apiKey),
...options.temperature === undefined ? {} : { temperature: options.temperature },
+8 -32
View File
@@ -6,7 +6,7 @@
import { brandString } from '@deepseek-ai/dsh-brand'
import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, projectOffloadedImages, requestImageHandleText, requiredImageOffload } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message, RequestMessage, SystemPromptUpdate, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message, RequestMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import type {
AttachmentId,
AttachmentStore,
@@ -129,33 +129,19 @@ function piTool(tool: NonNullable<GenerateOptions['tools']>[number]): PiTool {
return { name: tool.name, description: tool.description, parameters: tool.parameters }
}
/**
* Serialize system-message text and projected tool updates. Pending
* updates are emitted immediately before the next assistant message or at the
* end of the history, and must follow a user or tool-result message.
*/
/** Convert ordered history; pi-ai owns protocol placement and unsupported-model fallback. */
function conversationContext(
options: GenerateOptions,
history: readonly RequestMessage[],
inputContent: (message: RequestMessage) => string | (TextContent | ImageContent)[],
onReplayDegrade?: (reason: string) => void,
systemPromptUpdate?: SystemPromptUpdate,
): PiContext {
const messages: PiMessage[] = []
const systemUpdates: PiMessage[] = []
const toolNames = new Map<ToolCallId, string>()
const historySystem: string[] = []
const flushSystemUpdates = () => {
if (systemUpdates.length === 0) return
const previous = messages.at(-1)
if (previous?.role !== 'user' && previous?.role !== 'toolResult') {
throw new LlmError('pi-ai system update requires a preceding user or tool-result turn', 'UNSUPPORTED_CONTENT')
}
messages.push(...systemUpdates.splice(0))
}
for (const message of history) {
if (message.role === 'developer') {
appendDeveloper(message, options, systemUpdates)
appendDeveloper(message, options, messages)
continue
}
if (message.role === 'system') {
@@ -164,15 +150,14 @@ function conversationContext(
}
const text = flattenText(message)
if (text.length === 0) continue
if (systemPromptUpdate === 'in-history' && messages.length > 0) {
systemUpdates.push({ role: 'system', content: text, timestamp: 0 })
if (messages.length > 0) {
messages.push({ role: 'system', content: text, timestamp: 0 })
} else {
historySystem.push(text)
}
continue
}
if (message.role === 'assistant') {
flushSystemUpdates()
appendAssistant(message, messages, toolNames, onReplayDegrade)
continue
}
@@ -183,7 +168,6 @@ function conversationContext(
messages.push({ role: 'user', content, timestamp: 0 })
}
}
flushSystemUpdates()
const system = [options.system, ...historySystem].filter(Boolean).join('\n\n')
const tools = options.tools?.filter(tool => !tool.deferLoading).map(piTool)
// An explicit empty head keeps the first later update from becoming the initial prompt.
@@ -247,13 +231,12 @@ function appendAssistant(
function textOnlyContext(
options: GenerateOptions,
onReplayDegrade?: (reason: string) => void,
systemPromptUpdate?: SystemPromptUpdate,
): PiContext {
assertSupportedHistory(options.messages)
if (options.messages.some(message => contentHasImage(message.content))) {
throw new LlmError('pi-ai image conversion requires the durable attachment service', 'UNSUPPORTED_CONTENT')
}
return conversationContext(options, options.messages, flattenText, onReplayDegrade, systemPromptUpdate)
return conversationContext(options, options.messages, flattenText, onReplayDegrade)
}
/** Inputs that bind deterministic request images to one current tool execution world. */
@@ -287,7 +270,6 @@ function requestImageTarget(ref: ImageAttachmentRef, budget: PiImageRequestBudge
* @param options - projected request; one-shot system text precedes the leading history prompt.
* @param images - absent; selects the synchronous conversion.
* @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message.
* @param systemPromptUpdate - resolved route support for complete in-history system snapshots.
* @returns the pi-ai transcript with initial tools and ordered updates.
* @throws {LlmError} `UNSUPPORTED_CONTENT` for images in any history role, including a leading system message.
*/
@@ -295,7 +277,6 @@ export function toPiContext(
options: GenerateOptions,
images?: undefined,
onReplayDegrade?: (reason: string) => void,
systemPromptUpdate?: SystemPromptUpdate,
): PiContext
/**
* Convert harness history to a pi-ai Context while resolving durable images.
@@ -307,31 +288,27 @@ export function toPiContext(
* @param options - projected request; one-shot system text precedes the leading history prompt.
* @param images - attachment provider, current path resolver, and request limits.
* @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message.
* @param systemPromptUpdate - resolved route support for complete in-history system snapshots.
* @returns the asynchronously resolved pi-ai transcript.
*/
export function toPiContext(
options: GenerateOptions,
images: PiImageRequestContext,
onReplayDegrade?: (reason: string) => void,
systemPromptUpdate?: SystemPromptUpdate,
): Promise<PiContext>
export function toPiContext(
options: GenerateOptions,
images?: PiImageRequestContext,
onReplayDegrade?: (reason: string) => void,
systemPromptUpdate?: SystemPromptUpdate,
): PiContext | Promise<PiContext> {
return images === undefined
? textOnlyContext(options, onReplayDegrade, systemPromptUpdate)
: toPiContextWithImages(options, images, onReplayDegrade, systemPromptUpdate)
? textOnlyContext(options, onReplayDegrade)
: toPiContextWithImages(options, images, onReplayDegrade)
}
async function toPiContextWithImages(
options: GenerateOptions,
images: PiImageRequestContext,
onReplayDegrade?: (reason: string) => void,
systemPromptUpdate?: SystemPromptUpdate,
): Promise<PiContext> {
const { attachments, resolveImageAccess, maxRequestImageBytes } = images
const requestImagePolicy = images.requestImagePolicy ?? {
@@ -363,6 +340,5 @@ async function toPiContextWithImages(
exactMessages,
message => userContent(message.content, requestImages, resolveImageAccess),
onReplayDegrade,
systemPromptUpdate,
)
}
+23 -24
View File
@@ -522,20 +522,22 @@ describe('pi-ai system prompt source', () => {
await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected)
})
it('concatenates all system messages at the head when updates are unsupported', async () => {
it('preserves later system messages for pi-ai to resolve model support', async () => {
const options: GenerateOptions = { ...base, system: 'direct', messages: [leading, question,
history('system', [{ type: 'text', text: 'additional rule' }]),
leading, history('system', []),
] }
const expected = { messages: [
{ role: 'system', content: 'direct\n\nlead rule\n\nadditional rule\n\nlead rule', timestamp: 0 },
{ role: 'system', content: 'direct\n\nlead rule', timestamp: 0 },
{ role: 'user', content: 'hi', timestamp: 0 },
{ role: 'system', content: 'additional rule', timestamp: 0 },
{ role: 'system', content: 'lead rule', timestamp: 0 },
] }
expect(toPiContext(options)).toEqual(expected)
await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected)
})
it('preserves full repeated updates after user and tool results on both paths', async () => {
it('preserves repeated system updates in history order on both paths', async () => {
const callId = ToolCallId('lookup')
const snapshot = history('system', [{ type: 'text', text: 'lead rule\nnew rule' }])
const options: GenerateOptions = { ...base, system: 'direct', messages: [leading,
@@ -549,15 +551,15 @@ describe('pi-ai system prompt source', () => {
{ role: 'system', content: 'direct\n\nlead rule\n\nlead rule\nnew rule', timestamp: 0 },
{ role: 'user', content: 'hi', timestamp: 0 },
expect.objectContaining({ role: 'assistant' }),
{ role: 'system', content: 'lead rule\nnew rule', timestamp: 0 },
expect.objectContaining({ role: 'toolResult', content: [{ type: 'text', text: 'found' }] }),
{ role: 'system', content: 'lead rule\nnew rule', timestamp: 0 },
{ role: 'system', content: 'lead rule\nnew rule', timestamp: 0 },
] }
expect(toPiContext(options, undefined, undefined, 'in-history')).toEqual(expected)
await expect(toPiContext(options, imageContext(attachments), undefined, 'in-history')).resolves.toEqual(expected)
expect(toPiContext(options)).toEqual(expected)
await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected)
})
it('places complete prompt updates after user input and before the assistant response', async () => {
it('preserves prompt updates before the following user message', async () => {
const options: GenerateOptions = { ...base, messages: [leading, question,
history('assistant', [{ type: 'text', text: 'first answer' }]),
history('system', [{ type: 'text', text: 'replacement' }]),
@@ -568,15 +570,15 @@ describe('pi-ai system prompt source', () => {
{ role: 'system', content: 'lead rule', timestamp: 0 },
{ role: 'user', content: 'hi', timestamp: 0 },
expect.objectContaining({ role: 'assistant', content: [{ type: 'text', text: 'first answer' }] }),
{ role: 'user', content: 'next question', timestamp: 0 },
{ role: 'system', content: 'replacement', timestamp: 0 },
{ role: 'user', content: 'next question', timestamp: 0 },
expect.objectContaining({ role: 'assistant', content: [{ type: 'text', text: 'second answer' }] }),
] }
expect(toPiContext(options, undefined, undefined, 'in-history')).toEqual(expected)
await expect(toPiContext(options, imageContext(attachments), undefined, 'in-history')).resolves.toEqual(expected)
expect(toPiContext(options)).toEqual(expected)
await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected)
})
it.each([undefined, 'in-history'] as const)('skips empty system messages with %s support on both paths', async (support) => {
it('skips empty system messages on both paths', async () => {
const options: GenerateOptions = { ...base, messages: [
history('system', []), leading, question,
history('system', [{ type: 'text', text: '' }]),
@@ -588,8 +590,8 @@ describe('pi-ai system prompt source', () => {
{ role: 'user', content: 'hi', timestamp: 0 },
expect.objectContaining({ role: 'assistant' }),
] }
expect(toPiContext(options, undefined, undefined, support)).toEqual(expected)
await expect(toPiContext(options, imageContext(attachments), undefined, support)).resolves.toEqual(expected)
expect(toPiContext(options)).toEqual(expected)
await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected)
})
it('keeps a later update distinct from an absent initial prompt', async () => {
@@ -599,20 +601,17 @@ describe('pi-ai system prompt source', () => {
{ role: 'user', content: 'hi', timestamp: 0 },
{ role: 'system', content: 'lead rule', timestamp: 0 },
] }
expect(toPiContext(options, undefined, undefined, 'in-history')).toEqual(expected)
await expect(toPiContext(options, imageContext(attachments), undefined, 'in-history')).resolves.toEqual(expected)
expect(toPiContext(options)).toEqual(expected)
await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected)
})
it.each<{ label: string; messages: Message[]; error: string }>([
{ label: 'non-text system', messages: [history('system', [{ type: 'reasoning', text: 'hidden' }])], error: 'non-text system messages' },
{ label: 'update after assistant', messages: [question, history('assistant', [{ type: 'text', text: 'answer' }]), leading], error: 'preceding user or tool-result turn' },
])('rejects $label on both conversion paths', async ({ messages, error }) => {
const options: GenerateOptions = { ...base, messages }
expect(() => toPiContext(options, undefined, undefined, 'in-history')).toThrow(error)
await expect(toPiContext(options, imageContext(attachments), undefined, 'in-history')).rejects.toThrow(error)
it('rejects non-text system content on both conversion paths', async () => {
const options: GenerateOptions = { ...base, messages: [history('system', [{ type: 'reasoning', text: 'hidden' }])] }
expect(() => toPiContext(options)).toThrow('non-text system messages')
await expect(toPiContext(options, imageContext(attachments))).rejects.toThrow('non-text system messages')
})
it('keeps developer text and tool changes together after the following user turn', async () => {
it('keeps developer text and tool changes together at their recorded position', async () => {
const lookup = { name: 'lookup', description: 'initial', parameters: { type: 'object' } }
const search = { name: 'search', description: 'later', parameters: { type: 'object' }, deferLoading: true }
const update = createDeveloperMessage({ source: { kind: 'test' }, content: [
@@ -623,9 +622,9 @@ describe('pi-ai system prompt source', () => {
const options: GenerateOptions = { ...base, tools: [lookup, search], messages: [leading, update, question] }
const expected = { messages: [
{ role: 'system', content: 'lead rule', timestamp: 0, toolsAdded: [lookup] },
{ role: 'user', content: 'hi', timestamp: 0 },
{ role: 'system', content: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }], timestamp: 0,
toolsAdded: [{ name: 'search', description: 'later', parameters: { type: 'object' } }], toolsRemoved: [{ name: 'lookup' }] },
{ role: 'user', content: 'hi', timestamp: 0 },
] }
expect(toPiContext(options)).toEqual(expected)
await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected)
@@ -1,7 +1,7 @@
/** Capability-driven transcript updates through the real pi-ai serializers and shared runtime. */
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import LlmRuntime, { createDeveloperMessage, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import LlmRuntime, { createAssistantMessage, createDeveloperMessage, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, ToolSchema, ToolUpdate } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import type { Api, Model } from '@earendil-works/pi-ai'
@@ -143,14 +143,36 @@ describe('serialized conversation updates', () => {
])
})
it.each([
{ provider: 'moonshotai', modelId: 'kimi-k3', field: 'messages', role: 'system' },
{ provider: 'openai', modelId: 'gpt-5.4', field: 'input', role: 'developer' },
])('preserves update placement through $provider', async ({ provider, modelId, field, role }) => {
const { requests } = await send(provider, modelId, {
messages: [system('initial'), user('first'),
createAssistantMessage({ source: { provider, model: modelId }, content: [{ type: 'text', text: 'answer' }] }),
system('updated'), user('next')],
})
expect(requests).toHaveLength(1)
expect(requests[0]).toHaveProperty(field, [
expect.objectContaining({ content: 'initial' }),
expect.objectContaining({ role: 'user' }),
expect.objectContaining({ role: 'assistant' }),
expect.objectContaining({ role, content: 'updated' }),
expect.objectContaining({ role: 'user' }),
])
})
it('sends concatenated system messages and current tools on unsupported routes', async () => {
const update = addition()
const { requests } = await send('openai', 'gpt-4.1', {
messages: [system('initial'), user('first'), system('current'), user('next'), update], tools: [added],
system: 'direct',
messages: [system('initial'), user('first'), system('current'), user('next'), update,
createAssistantMessage({ source: { provider: 'openai', model: 'gpt-4.1' }, content: [{ type: 'text', text: 'answer' }] }),
system('current'), system('')], tools: [added],
toolHistory: { tools: [baseline], updates: [{ messageId: update.id, additions: [added] }] },
})
expect(requests).toHaveLength(1)
expect(requests[0]).toHaveProperty('input.0', { role: 'system', content: 'initial\n\ncurrent' })
expect(requests[0]).toHaveProperty('input.0', { role: 'system', content: 'direct\n\ninitial\n\ncurrent\n\ncurrent' })
expect(JSON.stringify(requests[0])).not.toContain('Instruction one.')
expect(JSON.stringify(requests[0])).not.toContain('additional_tools')
})