fix(session): preserve V3 result extensions in flat tool messages
The ordinary V3 writer records a user-role message containing one result wrapper. V4 writes the same tool output directly in a tool-role message. Keep that native V4 representation instead of introducing a nested message content type for hypothetical historical shapes. Preserve extra historical fields when removing the wrapper. Prefix outer message fields with plugin:message: and result fields with plugin:result: so equal names retain their original ownership and cannot overwrite the interpreted call ID or error flag. Include the complete old name, even when it is already prefixed, and preserve every JSON value and own key. Only the validated wrapper supplies the interpreted result fields. Leave unimplemented nested-result conversion as an explicit refusal that preserves its source. Describe this as the converter's current limit, not an impossibility or a reason to extend the established V4 format. Cover owner/name collisions, opaque special keys, absent error flags, unchanged sources and strict native reopening through plain and compressed read/write persistence. No current message type or schema is added.
This commit is contained in:
1 parent
869367d5d0
commit
0c8180c9b6
9 files changed
+99
-75
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.md
|
||||
2026-09-15-first-class-tool-role-messages.md: c16bc44116b1b2c20c988db4b6977ee31bea4ab4
|
||||
2026-09-15-first-class-tool-role-messages.zh.md: f9eb581f6c080655145b3c9ab1c55d052fe871b8
|
||||
2026-09-15-first-class-tool-role-messages.md: 09f73c6d9b9c0196b3b09b5483d9e833cbc08837
|
||||
2026-09-15-first-class-tool-role-messages.zh.md: 549838faf6855f4d3206c5bb05964f2f9be435f2
|
||||
@@ -12,7 +12,7 @@ The released Session format stores tool results inside a user-role `tool-result`
|
||||
|
||||
Session format V4 stores one tool result as a first-class `role: 'tool'` message. Its `toolCallId`, tool source call id, result content, and optional error flag are validated together. The V3-to-V4 edge lifts exactly one released wrapper and refuses nested wrappers before publication, retaining the source generation. Current V4 decoding is native-only; wrapper rows are accepted only by the adjacent migration edge.
|
||||
|
||||
Outer message JSON extensions retain their own properties during lifting, including `__proto__` and `constructor`. An existing `toolCallId` must match the source call; an existing boolean `isError` must match the wrapper's error state, with omission meaning non-error. Unknown wrapper fields and conflicting outer fields refuse migration because the new representation provides no safe interpretation for them. Rebuilding only known message fields would silently delete admitted V3 data; copying wrapper extensions onto the message would invent their scope.
|
||||
Historical message and result extension fields keep distinct `plugin:message:` and `plugin:result:` names when their wrapper is removed. Complete original field names remain in each suffix, preventing collisions without adding core metadata fields. The wrapper alone supplies the interpreted call id and error flag; an old outer field with the same spelling remains opaque. The [conversion specification](../../../../packages/session/session-format-v3-to-v4/README.md#tool-results) owns exact preservation and refusal rules.
|
||||
|
||||
The conversation role map is closed. A model-visible role must have a persisted Session event and an adapter projection, so adapters do not silently turn an unlogged extension role into user text. Provider-specific source kinds remain merge-extensible and are owned by their producers.
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
Session format V4 将一次工具结果存储为一等 `role: 'tool'` 消息。它的 `toolCallId`、工具来源调用标识、结果内容和可选错误标记一起校验。V3 到 V4 的边缘迁移只提升一个已发布的包装,并在发布前拒绝嵌套包装,保留源代际。当前 V4 解码仅接受原生形式;包装行只由相邻迁移边缘接受。
|
||||
|
||||
外层消息的 JSON 扩展在提升时保留为自有属性,包括 `__proto__` 和 `constructor`。已有 `toolCallId` 必须与 source call 一致;已有布尔 `isError` 必须与 wrapper 的错误状态一致,省略表示非错误。未知 wrapper 字段和冲突的外层字段会拒绝迁移,因为新表示没有安全的解释方式。只重建已知消息字段会静默删除 V3 已接受的数据;将 wrapper 扩展复制到消息上则会凭空改变其作用范围。
|
||||
移除历史 wrapper 时,消息与结果的扩展字段分别保留为 `plugin:message:` 和 `plugin:result:` 名称。后缀保留完整原字段名,避免冲突且不添加核心 metadata 字段。只有 wrapper 提供具有解释语义的调用 id 和错误标志;同名的旧外层字段保持不透明。[转换规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#tool-results)拥有精确的保留与拒绝规则。
|
||||
|
||||
对话角色 map 是封闭的。模型可见角色必须有持久 Session 事件和适配器投影,因此适配器不会把未记录的扩展角色静默转换为 user 文本。提供方特有的来源 kind 仍然可合并扩展,并由各生产方拥有。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md
|
||||
README.md: 5a251c8430e358f16216a0fdd09497bd29b463d8
|
||||
README.zh.md: c706e7f99d097e1ca6c1b4db2653ed9b7a6f1955
|
||||
README.md: 2c2d3b4342d68cec5e9a9f94f969a380bbbf15f2
|
||||
README.zh.md: a92ae34a73f134253880fec3f931815e067bc344
|
||||
@@ -91,11 +91,11 @@ No preset id, PTC dispatch event tag, file attachment, or physical filename is r
|
||||
| `data.message.content[0].content` | Direct `data.message.content`, including empty content |
|
||||
| `data.message.content[0].isError` | Optional `data.message.isError` |
|
||||
| Wrapper `type: 'tool-result'` | Removed with the wrapper |
|
||||
| Message `id`, `source`, outer extensions, event fields | Retained; no message or call id is minted |
|
||||
| Message `id`, `source`, event fields | Retained; no message or call id is minted |
|
||||
|
||||
The only admitted wrapper keys are `type`, `toolCallId`, `content`, and `isError`. An unknown wrapper key has no defined target and refuses migration. Nested `tool-result` blocks inside its result content are refused because flattening cannot preserve their independent call identity and error status. Existing outer `toolCallId` must match; existing outer `isError` must be boolean and equal the wrapper's value, treating omission as false for this conflict check. When the wrapper omits `isError`, an existing consistent outer value remains present. Outer JSON properties such as `__proto__` and `constructor` remain own data properties.
|
||||
The wrapper alone supplies the interpreted call id, content, and optional error flag. Other wrapper fields become `plugin:result:<original-field>`; outer message fields other than `id`, `role`, `source`, and `content` become `plugin:message:<original-field>`. Complete original names remain in the suffix, including existing prefixes. Distinct owners and colliding names retain separate values; own `__proto__` and `constructor` data stays intact. No metadata container or new content type is added.
|
||||
|
||||
A malformed canonical wrapper raises a format error; an unmapped wrapper extension, nested result, or conflicting target field raises an unsupported-migration error. The transformation does not repair contradictory `data.error`; native target validation requires it to accompany `message.isError: true`.
|
||||
A malformed canonical wrapper raises a format error. Nested results are unsupported by this converter and refuse without publishing a successor. The transformation does not repair contradictory `data.error`; native target validation requires it to accompany the wrapper's `isError: true`. Converter support may expand later while preserving the established native V4 representation.
|
||||
|
||||
<a id="extension-data"></a>
|
||||
### Extension data
|
||||
@@ -331,8 +331,8 @@ The edge preserves the recorded request prefix. Provider cache availability and
|
||||
- **Historical converter coverage** — Unsupported source forms may fail without publishing a successor or changing the source. First-party recordings do not enumerate third-party extensions. Later converter fixes may add support after V4 publication if their V4 output remains compatible. Interpreting extra stream-start fields and preparing future delivery generations require a concrete format change.
|
||||
- **Accepted V4 transition** — the [checkpoint](../../../docs/session-format-status.md#finalization-record) protects the accepted history. Backward-compatible additions can remain V4 through new acknowledgements; breaking changes require a successor. Already-written V4 files do not rerun this incoming edge, and historical inputs remain intact.
|
||||
- **V5 prerequisite readers** — V4 child evidence currently goes through the installed catalog. A future writer must bind fixed-generation V4 prerequisite reading before changing that catalog. The exported V4 restorer supplies generation-owned checks; full common message admission additionally uses installed Session validation.
|
||||
- **Nested historical tool results** — migration refuses results containing another tool-result wrapper because flattening loses its call identity and error status. The original generation remains intact and no V4 successor is published; those histories need a preserving conversion before they can resume.
|
||||
- **Historical tool-result extensions** — outer message JSON properties remain own data properties, including `__proto__` and `constructor`. Unknown wrapper fields have no defined V4 destination and refuse migration. Existing outer `toolCallId` or `isError` fields must agree with the lifted result; conflicts refuse without publishing a successor.
|
||||
- **Nested historical tool results** — migration currently refuses results containing another tool-result wrapper. The original generation remains intact and no V4 successor is published. A later converter may support evidenced source cases without changing the established V4 format; the [migration cookbook](../../../docs/cookbook/adding-a-session-format-version.md#stages-and-validation) defines that distinction.
|
||||
- **Historical extension consumers** — prefixed message and result fields preserve JSON data without activating core fields. A consumer must explicitly understand those fields before interpreting them.
|
||||
- **Retained child logs required** — a parent alone cannot recover unrecorded child ids, creation times, or descriptors. Deleted children cannot be reconstructed from tool arguments; existing parent catalog records remain.
|
||||
- **Missing historical catalog entries** — without exactly one supported own descriptor, an absent parent entry is not backfilled. The child log remains readable by id; current V4 reads do not rescan children to repair that omission.
|
||||
- **Storage scope** — facts cover recognizable children within the same persistence root. Cross-root import and corrupt-log repair are outside this migration.
|
||||
|
||||
@@ -91,11 +91,11 @@ const artifact = restore.finish()
|
||||
| `data.message.content[0].content` | 直接作为 `data.message.content`,包括空内容 |
|
||||
| `data.message.content[0].isError` | 可选的 `data.message.isError` |
|
||||
| Wrapper 的 `type: 'tool-result'` | 随 wrapper 移除 |
|
||||
| 消息 `id`、`source`、外层扩展和事件字段 | 保留;不生成新的消息或调用 id |
|
||||
| 消息 `id`、`source` 和事件字段 | 保留;不生成新的消息或调用 id |
|
||||
|
||||
Wrapper 只允许 `type`、`toolCallId`、`content` 和 `isError`。未知 wrapper 字段没有定义好的目标,因此拒绝迁移。结果内容中嵌套的 `tool-result` block 也被拒绝,因为展平无法保留它们独立的调用身份和错误状态。已有外层 `toolCallId` 必须匹配;已有外层 `isError` 必须为布尔值,并与 wrapper 值一致,此冲突检查把省略视为 false。Wrapper 省略 `isError` 时,一致的已有外层值仍保留。`__proto__` 和 `constructor` 等外层 JSON 属性仍是自有数据属性。
|
||||
只有 wrapper 提供具有解释语义的调用 id、content 和可选错误标志。Wrapper 的其他字段变为 `plugin:result:<原字段名>`;外层消息除 `id`、`role`、`source` 和 `content` 外的字段变为 `plugin:message:<原字段名>`。后缀保留完整原名称,包括已有前缀。不同 owner 与重名字段分别保留值;`__proto__` 和 `constructor` 的自有数据保持完整。不添加 metadata 容器或新的内容类型。
|
||||
|
||||
格式错误的 canonical wrapper 产生格式错误;没有映射的 wrapper 扩展、嵌套结果或冲突目标字段产生不支持迁移错误。转换不修复矛盾的 `data.error`;原生目标校验要求它与 `message.isError: true` 同时出现。
|
||||
格式错误的 canonical wrapper 引发格式错误。当前转换器不支持嵌套结果,遇到时拒绝且不发布 successor。转换不会修复矛盾的 `data.error`;原生目标校验要求它与 wrapper 的 `isError: true` 同时出现。后续可以扩展转换器支持范围,同时保持既定的原生 V4 表示。
|
||||
|
||||
<a id="extension-data"></a>
|
||||
### 扩展数据
|
||||
@@ -331,8 +331,8 @@ Fork 种子构造归核心 Session 所有,不属于此迁移。原生 V4 接
|
||||
- **历史转换器覆盖范围** — 未支持的源表示可能拒绝迁移,不发布后继文件,也不修改源文件。一方录制不等于第三方扩展全集。V4 发布后,只要输出仍兼容 V4,后续转换器修复就可以增加支持。解释流起始块的额外字段或处理未来投递代际,应以具体格式变更为依据。
|
||||
- **已接受 V4 转换**——[检查点](../../../docs/session-format-status.zh.md#finalization-record)保护已接受历史。向后兼容的新增可以通过新的确认记录保留 V4;破坏性变更要求后继版本。已写入的 V4 文件不会重跑此入边,历史输入保持不变。
|
||||
- **V5 前置读取器**——V4 子日志证据目前经过已安装目录。后续写入器在改变该目录前,须绑定固定代际的 V4 前置读取。导出的 V4 恢复器提供代际自有检查;完整的通用消息接纳还使用已安装的 Session 校验。
|
||||
- **历史嵌套工具结果**——迁移会拒绝包含另一层 tool-result 包装的结果,因为展平会丢失其调用身份和错误状态。原始代际保持不变,也不会发布 V4 后继;这些历史需要能保留信息的转换才能继续运行。
|
||||
- **历史工具结果扩展**——外层消息的 JSON 属性仍保留为自有数据属性,包括 `__proto__` 和 `constructor`。未知 wrapper 字段没有已定义的 V4 存放位置,因此拒绝迁移。已有外层 `toolCallId` 或 `isError` 字段必须与提升后的结果一致;冲突时拒绝且不发布 successor。
|
||||
- **历史嵌套工具结果**——当前迁移拒绝包含另一个 tool-result wrapper 的结果。原始代际保持完整,且不发布 V4 successor。后续转换器可以支持有证据的源数据场景,而不改变既定 V4 格式;[迁移 cookbook](../../../docs/cookbook/adding-a-session-format-version.zh.md#stages-and-validation) 定义了这一区别。
|
||||
- **历史扩展消费者**——带前缀的消息与结果字段保留 JSON 数据,不激活核心字段。消费者必须明确理解这些字段后才能解释它们。
|
||||
- **依赖保留的子日志**——仅凭父日志无法恢复未记录的子 id、创建时间或 descriptor。删除的子 Session 无法从工具参数恢复;已存在的父目录记录仍保留。
|
||||
- **历史目录项缺失**——没有恰好一个受支持的自身 descriptor 时,不补填父目录缺失项。子日志仍可按 id 读取;当前 V4 读取不会重新扫描子日志来补齐该条目。
|
||||
- **存储范围**——事实只覆盖同一持久化根目录内可识别的子 Session。跨根目录导入和损坏日志修复不属于此迁移。
|
||||
|
||||
@@ -4,12 +4,21 @@ import { SessionFormatError, SessionFormatUnsupportedMigrationError, isSessionFo
|
||||
import type { SessionFormatEvent, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
|
||||
|
||||
const WRAPPER_FIELDS = new Set(['type', 'toolCallId', 'content', 'isError'])
|
||||
const MESSAGE_FIELDS = new Set(['id', 'role', 'source', 'content'])
|
||||
|
||||
/** Preserve unknown fields without merging their original message and result owners. */
|
||||
function extensionFields(
|
||||
value: Record<string, SessionFormatJsonValue>, fields: ReadonlySet<string>, owner: 'message' | 'result',
|
||||
): Record<string, SessionFormatJsonValue> {
|
||||
return Object.fromEntries(Object.entries(value).filter(([key]) => !fields.has(key))
|
||||
.map(([key, field]) => [`plugin:${owner}:${key}`, field]))
|
||||
}
|
||||
|
||||
function resultContent(value: SessionFormatJsonValue | undefined, subject: string): SessionFormatJsonValue[] {
|
||||
if (!Array.isArray(value)) throw new SessionFormatError(`${subject} tool-result content must be an array`)
|
||||
if (value.some(block => isSessionFormatJsonObject(block) && block['type'] === 'tool-result')) {
|
||||
throw new SessionFormatUnsupportedMigrationError(
|
||||
`${subject} contains a nested tool-result; migration cannot preserve its call identity and error status`,
|
||||
`${subject} contains a nested tool-result unsupported by this converter`,
|
||||
)
|
||||
}
|
||||
return value as SessionFormatJsonValue[]
|
||||
@@ -21,8 +30,7 @@ function resultContent(value: SessionFormatJsonValue | undefined, subject: strin
|
||||
* rejected instead of being silently truncated.
|
||||
* @param event - released wrapper tool/result event.
|
||||
* @returns the same event with a first-class tool-role message.
|
||||
* @throws {SessionFormatUnsupportedMigrationError} when nested results, wrapper
|
||||
* extensions, or conflicting target fields cannot be preserved.
|
||||
* @throws {SessionFormatUnsupportedMigrationError} when the result contains another result.
|
||||
*/
|
||||
export function liftToolResult(event: SessionFormatEvent): SessionFormatEvent {
|
||||
if (event.type !== 'tool/result' || !isSessionFormatJsonObject(event.data)) return event
|
||||
@@ -32,9 +40,10 @@ export function liftToolResult(event: SessionFormatEvent): SessionFormatEvent {
|
||||
const source = message['source']
|
||||
const callId = isSessionFormatJsonObject(source) ? source['callId'] : undefined
|
||||
const content = message['content']
|
||||
const wrapper = Array.isArray(content) && content.length === 1 && isSessionFormatJsonObject(content[0])
|
||||
? content[0]
|
||||
const block = Array.isArray(content) && content.length === 1
|
||||
? (content as readonly SessionFormatJsonValue[])[0]
|
||||
: undefined
|
||||
const wrapper = isSessionFormatJsonObject(block) ? block : undefined
|
||||
const id = message['id']
|
||||
if (typeof id !== 'string' || id.length === 0
|
||||
|| !isSessionFormatJsonObject(source) || source['kind'] !== 'tool'
|
||||
@@ -47,30 +56,18 @@ export function liftToolResult(event: SessionFormatEvent): SessionFormatEvent {
|
||||
if (isError !== undefined && typeof isError !== 'boolean') {
|
||||
throw new SessionFormatError(`format v3 ${event.type} at seq ${event.seq} tool-result isError must be boolean`)
|
||||
}
|
||||
const unmapped = Object.keys(wrapper).find(field => !WRAPPER_FIELDS.has(field))
|
||||
if (unmapped !== undefined) {
|
||||
throw new SessionFormatUnsupportedMigrationError(
|
||||
`format v3 ${event.type} at seq ${event.seq} has unmapped tool-result field ${JSON.stringify(unmapped)}`,
|
||||
)
|
||||
}
|
||||
if (Object.hasOwn(message, 'toolCallId') && message['toolCallId'] !== callId) {
|
||||
throw new SessionFormatUnsupportedMigrationError(`format v3 ${event.type} at seq ${event.seq} has conflicting outer toolCallId`)
|
||||
}
|
||||
if (Object.hasOwn(message, 'isError') && (typeof message['isError'] !== 'boolean' || message['isError'] !== (isError ?? false))) {
|
||||
throw new SessionFormatUnsupportedMigrationError(`format v3 ${event.type} at seq ${event.seq} has conflicting outer isError`)
|
||||
}
|
||||
const targetIsError = isError ?? message['isError']
|
||||
// Recorded replay compares serialized messages with createToolResultMessage's field order.
|
||||
const targetMessage: Record<string, SessionFormatJsonValue> = {
|
||||
role: 'tool',
|
||||
source,
|
||||
toolCallId: callId,
|
||||
content: resultContent(wrapper['content'] as SessionFormatJsonValue | undefined, `format v3 ${event.type} at seq ${event.seq}`),
|
||||
...(targetIsError === undefined ? {} : { isError: targetIsError }),
|
||||
content: resultContent(wrapper['content'], `format v3 ${event.type} at seq ${event.seq}`),
|
||||
...(isError === undefined ? {} : { isError }),
|
||||
id,
|
||||
...extensionFields(message, MESSAGE_FIELDS, 'message'),
|
||||
...extensionFields(wrapper, WRAPPER_FIELDS, 'result'),
|
||||
}
|
||||
const extensions = Object.fromEntries(Object.entries(message).filter(([key]) => !Object.hasOwn(targetMessage, key)))
|
||||
return { ...event, data: { ...data, message: { ...targetMessage, ...extensions } } }
|
||||
return { ...event, data: { ...data, message: targetMessage } }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,10 +50,10 @@ describe('V4 tool-role view and lift', () => {
|
||||
expect(JSON.stringify(target)).toBe(JSON.stringify({ ...current, id: 'tool-result-call-1' }))
|
||||
})
|
||||
|
||||
it.each([undefined, false, true])('preserves own outer message metadata and matching target fields with isError=%s', (isError) => {
|
||||
it.each([undefined, false, true])('preserves outer fields without activating their target names with isError=%s', (isError) => {
|
||||
const metadata = JSON.parse('{"__proto__":{"saved":"prototype"},"constructor":{"saved":"constructor"},"extension":{"saved":true}}') as SessionFormatJsonObject
|
||||
const content = [{ type: 'text', text: 'result' }]
|
||||
const message = {
|
||||
const message: SessionFormatJsonObject = {
|
||||
...metadata, ...wrapperMessage(), toolCallId: 'call-1', ...(isError === undefined ? {} : { isError }),
|
||||
content: [{ type: 'tool-result', toolCallId: 'call-1', content, ...(isError === undefined ? {} : { isError }) }],
|
||||
}
|
||||
@@ -61,9 +61,14 @@ describe('V4 tool-role view and lift', () => {
|
||||
const before = structuredClone(row)
|
||||
const lifted = liftToolResult(row)
|
||||
const target = (lifted.data as SessionFormatJsonObject)['message'] as SessionFormatJsonObject
|
||||
expect(target).toEqual({ ...message, role: 'tool', content })
|
||||
expect(Object.hasOwn(target, '__proto__')).toBe(true)
|
||||
expect(Object.hasOwn(target, 'constructor')).toBe(true)
|
||||
expect(target).toEqual({ id: message['id'], role: 'tool', source: message['source'], toolCallId: 'call-1', content,
|
||||
...(isError === undefined ? {} : { isError, 'plugin:message:isError': isError }),
|
||||
'plugin:message:toolCallId': 'call-1',
|
||||
'plugin:message:__proto__': metadata['__proto__'], 'plugin:message:constructor': metadata['constructor'],
|
||||
'plugin:message:extension': metadata['extension'],
|
||||
})
|
||||
expect(Object.hasOwn(target, 'plugin:message:__proto__')).toBe(true)
|
||||
expect(Object.hasOwn(target, 'plugin:message:constructor')).toBe(true)
|
||||
expect(Object.getPrototypeOf(target)).toBe(Object.prototype)
|
||||
expect(row).toEqual(before)
|
||||
expect(JSON.parse(JSON.stringify(releasedV4SessionFormatCodec.encodeEvent(lifted)))).toEqual(lifted)
|
||||
@@ -74,35 +79,51 @@ describe('V4 tool-role view and lift', () => {
|
||||
['isError', true, false],
|
||||
['isError', true, undefined],
|
||||
['isError', 'false', false],
|
||||
] as const)('refuses an outer %s field whose meaning conflicts with the lifted value', (field, value, isError) => {
|
||||
] as const)('keeps outer %s data separate from the interpreted result', (field, value, isError) => {
|
||||
const row = event(0, 'tool/result', { turn: 1, step: 1, message: {
|
||||
...wrapperMessage(), [field]: value,
|
||||
content: [{ type: 'tool-result', toolCallId: 'call-1', content: [], ...(isError === undefined ? {} : { isError }) }],
|
||||
} })
|
||||
const before = structuredClone(row)
|
||||
expect(() => liftToolResult(row)).toThrow(SessionFormatUnsupportedMigrationError)
|
||||
expect(() => liftToolResult(row)).toThrow(`conflicting outer ${field}`)
|
||||
const target = (liftToolResult(row).data as SessionFormatJsonObject)['message'] as SessionFormatJsonObject
|
||||
expect(target[`plugin:message:${field}`]).toEqual(value)
|
||||
expect(target['toolCallId']).toBe('call-1')
|
||||
expect(target['isError']).toBe(isError)
|
||||
expect(row).toEqual(before)
|
||||
})
|
||||
|
||||
it('preserves an outer false flag when the wrapper omits its equivalent non-error flag', () => {
|
||||
it('preserves an outer flag without adding an absent result flag', () => {
|
||||
const row = event(0, 'tool/result', { turn: 1, step: 1, message: {
|
||||
...wrapperMessage(), isError: false, content: [{ type: 'tool-result', toolCallId: 'call-1', content: [] }],
|
||||
} })
|
||||
expect(liftToolResult(row).data).toMatchObject({ message: { role: 'tool', isError: false } })
|
||||
expect(liftToolResult(row).data).toMatchObject({ message: { role: 'tool', 'plugin:message:isError': false } })
|
||||
expect((liftToolResult(row).data as SessionFormatJsonObject)['message']).not.toHaveProperty('isError')
|
||||
})
|
||||
|
||||
it.each(['extension', '__proto__', 'constructor'])('refuses unmapped wrapper field %s without losing it', (field) => {
|
||||
it.each(['extension', '__proto__', 'constructor'])('preserves result-owned %s without adding a core field', (field) => {
|
||||
const wrapper = Object.fromEntries([
|
||||
['type', 'tool-result'], ['toolCallId', 'call-1'], ['content', []], [field, { saved: true }],
|
||||
]) as SessionFormatJsonObject
|
||||
const row = event(0, 'tool/result', { turn: 1, step: 1, message: { ...wrapperMessage(), content: [wrapper] } })
|
||||
const before = structuredClone(row)
|
||||
expect(() => liftToolResult(row)).toThrow(SessionFormatUnsupportedMigrationError)
|
||||
expect(() => liftToolResult(row)).toThrow('unmapped tool-result field')
|
||||
const target = (liftToolResult(row).data as SessionFormatJsonObject)['message'] as SessionFormatJsonObject
|
||||
expect(target[`plugin:result:${field}`]).toEqual({ saved: true })
|
||||
expect(Object.hasOwn(target, `plugin:result:${field}`)).toBe(true)
|
||||
expect(Object.getPrototypeOf(target)).toBe(Object.prototype)
|
||||
expect(row).toEqual(before)
|
||||
})
|
||||
|
||||
it('keeps repeated owner names and already-prefixed field names distinct', () => {
|
||||
const row = event(0, 'tool/result', { turn: 1, step: 1, message: {
|
||||
...wrapperMessage(), note: 'outer', 'plugin:result:note': 'outer prefixed',
|
||||
content: [{ type: 'tool-result', toolCallId: 'call-1', content: [], note: 'inner', 'plugin:message:note': 'inner prefixed' }],
|
||||
} })
|
||||
expect(liftToolResult(row).data).toMatchObject({ message: {
|
||||
'plugin:message:note': 'outer', 'plugin:message:plugin:result:note': 'outer prefixed',
|
||||
'plugin:result:note': 'inner', 'plugin:result:plugin:message:note': 'inner prefixed',
|
||||
} })
|
||||
})
|
||||
|
||||
it('lifts a released wrapper into the native tool-role message', () => {
|
||||
const wrapperRow = { ...event(0, 'tool/result', { turn: 1, step: 1, message: wrapperMessage() }), surfaceOp: 'append' } as SessionFormatEvent
|
||||
const lifted = liftToolResult(wrapperRow)
|
||||
@@ -192,7 +213,7 @@ describe('V4 tool-role view and lift', () => {
|
||||
})
|
||||
const before = structuredClone(row)
|
||||
expect(() => liftToolResult(row)).toThrow(SessionFormatUnsupportedMigrationError)
|
||||
expect(() => liftToolResult(row)).toThrow('cannot preserve its call identity and error status')
|
||||
expect(() => liftToolResult(row)).toThrow('unsupported by this converter')
|
||||
expect(row).toEqual(before)
|
||||
})
|
||||
|
||||
|
||||
@@ -419,7 +419,7 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres
|
||||
await expectOnlyGenerations([path])
|
||||
})
|
||||
|
||||
it('preserves a V3 nested tool result when its identity and error status cannot migrate', async () => {
|
||||
it('leaves the V3 source unchanged when nested results are unsupported', async () => {
|
||||
const rows = releasedToolRows({}, { content: [
|
||||
{ type: 'tool-result', toolCallId: 'nested-call', isError: true, content: [{ type: 'text', text: 'inner failure' }] },
|
||||
] })
|
||||
@@ -427,36 +427,39 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres
|
||||
const original = await observe(path)
|
||||
const ctx = await mount(compression)
|
||||
await expectRefusal(ctx, access, path,
|
||||
'format v3 tool/result at seq 7 contains a nested tool-result; migration cannot preserve its call identity and error status'
|
||||
+ '; source v3 artifact remains unchanged (raw log: ' + path + ')')
|
||||
expect(await observe(path)).toEqual(original)
|
||||
await expectOnlyGenerations([path])
|
||||
})
|
||||
|
||||
it.each(['extension', '__proto__', 'constructor'])('refuses a V3 wrapper with unmapped %s metadata without publishing', async (field) => {
|
||||
const rows = releasedToolRows({}, Object.fromEntries([[field, { saved: true }]]))
|
||||
const path = await store(3, compression, rows)
|
||||
const original = await observe(path)
|
||||
const ctx = await mount(compression)
|
||||
await expectRefusal(ctx, access, path,
|
||||
`format v3 tool/result at seq 7 has unmapped tool-result field ${JSON.stringify(field)}`
|
||||
'format v3 tool/result at seq 7 contains a nested tool-result unsupported by this converter'
|
||||
+ '; source v3 artifact remains unchanged (raw log: ' + path + ')')
|
||||
expect(await observe(path)).toEqual(original)
|
||||
await expectOnlyGenerations([path])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ field: 'toolCallId', value: 'conflicting-call' },
|
||||
{ field: 'isError', value: true },
|
||||
])('refuses a conflicting outer $field before publishing', async ({ field, value }) => {
|
||||
const rows = releasedToolRows({ [field]: value })
|
||||
{ owner: 'result', field: 'extension', value: { saved: true } },
|
||||
{ owner: 'result', field: '__proto__', value: { saved: true } },
|
||||
{ owner: 'result', field: 'constructor', value: { saved: true } },
|
||||
{ owner: 'message', field: 'toolCallId', value: 'conflicting-call' },
|
||||
{ owner: 'message', field: 'isError', value: true },
|
||||
] as const)('preserves $owner-owned $field through migration and native reopening', async ({ owner, field, value }) => {
|
||||
const extra = Object.fromEntries([[field, value]])
|
||||
const rows = owner === 'message' ? releasedToolRows(extra) : releasedToolRows({}, extra)
|
||||
const path = await store(3, compression, rows)
|
||||
const original = await observe(path)
|
||||
const ctx = await mount(compression)
|
||||
await expectRefusal(ctx, access, path, `format v3 tool/result at seq 7 has conflicting outer ${field}`
|
||||
+ '; source v3 artifact remains unchanged (raw log: ' + path + ')')
|
||||
for (const mode of [access, 'read'] as const) {
|
||||
const handle = await ctx.sessionPersistence.open(id, mode)
|
||||
try {
|
||||
const result = (await handle.read()).events.find(event => event.type === 'tool/result')
|
||||
expect(result?.data.message).toMatchObject({
|
||||
role: 'tool', toolCallId: 'outer-call', isError: false,
|
||||
[`plugin:${owner}:${field}`]: value,
|
||||
})
|
||||
expect(Object.hasOwn(result!.data.message, `plugin:${owner}:${field}`)).toBe(true)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
expect(await observe(path)).toEqual(original)
|
||||
await expectOnlyGenerations([path])
|
||||
await expectOnlyGenerations(access === 'read' ? [path] : [path, generationLogPath(root, undefined, id, SESSION_FORMAT_VERSION, compression)])
|
||||
})
|
||||
|
||||
it('preserves outer V3 tool-message metadata through migration and native reopening', async () => {
|
||||
@@ -465,8 +468,11 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres
|
||||
const original = await observe(path)
|
||||
const ctx = await mount(compression)
|
||||
const expected = {
|
||||
...metadata, id: 'result', role: 'tool', source: { kind: 'tool', callId: 'outer-call' },
|
||||
id: 'result', role: 'tool', source: { kind: 'tool', callId: 'outer-call' },
|
||||
toolCallId: 'outer-call', isError: false, content: [{ type: 'text', text: 'result' }],
|
||||
'plugin:message:toolCallId': 'outer-call', 'plugin:message:isError': false,
|
||||
'plugin:message:__proto__': metadata['__proto__'], 'plugin:message:constructor': metadata['constructor'],
|
||||
'plugin:message:extension': metadata['extension'],
|
||||
}
|
||||
for (const mode of [access, 'read'] as const) {
|
||||
const handle = await ctx.sessionPersistence.open(id, mode)
|
||||
@@ -474,7 +480,7 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres
|
||||
const restored = await handle.read()
|
||||
const result = restored.events.find(event => event.type === 'tool/result')
|
||||
expect(result?.data.message).toEqual(expected)
|
||||
expect(Object.hasOwn(result!.data.message, '__proto__')).toBe(true)
|
||||
expect(Object.hasOwn(result!.data.message, 'plugin:message:__proto__')).toBe(true)
|
||||
expect(Object.getPrototypeOf(result!.data.message)).toBe(Object.prototype)
|
||||
} finally {
|
||||
await handle.close()
|
||||
|
||||
Reference in new issue
Block a user