Skip to content

Commit 0e120c6

Browse files
author
SqlRush
committed
Accept message text content aliases
1 parent ed360e3 commit 0e120c6

6 files changed

Lines changed: 80 additions & 6 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ M6 补充:transcript resume 的嵌套 content block 现在接受 `toolUseId`/`
3636

3737
M6 补充:transcript resume 的 nested content block `id`/`tool_use_id`/`toolUseId` 现在接受 JSON number,并保留为字符串 tool-use ID。
3838

39-
M6 补充:嵌套 contract message 的 `content` 现在接受字符串和单个 content-block 对象;字符串会归一为 text block,并接受 `role`/`messageType` 作为 message type 别名,提升 transcript/remote history payload 恢复率。
39+
M6 补充:嵌套 contract message 的 `content` 现在接受字符串、单个 content-block 对象,以及混合字符串/content-block 数组;字符串会归一为 text block,并接受 `text`/`body`/`message`/`value`/`output` 正文字段和 `role`/`messageType` 类型别名,提升 transcript/remote history payload 恢复率。
4040

4141
M6 补充:嵌套 contract message 现在接受 `parentUUID``parentId`/`parentID`/`parent_id``parentMessageId`/`parentMessageID`/`parent_message_id` 和 parent-message UUID 别名,transcript/remote history payload 自带 parent alias 时不会丢失嵌套 parent。
4242

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ test/parity/ # golden tests against TS/official behavior
203203
- 本轮补充:remote history `SDKEvent` 本体接受 `eventType`/`event_type`/`role` 类型别名、`createdAt`/`created_at` 时间戳别名,以及 `payload`/`data`/`body`/`serializedMessage` message payload 别名;payload 只有 `role`/`content` 时也能 materialize 成 transcript message。
204204
- 本轮补充:transcript resume 的嵌套 content block 接受 `toolUseId`/`toolUseID``isError``cacheControl``cacheReference` 字段别名,并保留 cache edit 的 `cacheReference`
205205
- 本轮补充:transcript resume 的 nested content block `id`/`tool_use_id`/`toolUseId` 现在接受 JSON number,并保留为字符串 tool-use ID。
206-
- 本轮补充:嵌套 contract message 的 `content` 接受字符串和单个 content-block 对象;字符串会归一为 text block,并接受 `role`/`messageType` 作为 message type 别名,提升 transcript/remote history payload 恢复率。
206+
- 本轮补充:嵌套 contract message 的 `content` 接受字符串、单个 content-block 对象,以及混合字符串/content-block 数组;字符串会归一为 text block,并接受 `text`/`body`/`message`/`value`/`output` 正文字段和 `role`/`messageType` 类型别名,提升 transcript/remote history payload 恢复率。
207207
- 本轮补充:lightweight transcript metadata loader 在 `system`/`compact_boundary` 后清空旧 `marble-origami-commit`/`marble-origami-snapshot` 状态,和 full loader/官方 sessionStorage compact-boundary 语义一致。
208208
- 本轮补充:transcript metadata loader 接受 `sessionID`/`session` 作为 session-scoped metadata ID 别名,并容忍 `prNumber``timeSavedMs``lastSpawnTokens` 等计数字段使用数字字符串。
209209
- 本轮补充:transcript metadata ID helper 现在复用 contract `ID` JSON 解码,`messageID`/`sessionID` 等 metadata ID 字段可接受 JSON number 并保留为字符串。

docs/first-second-parity-audit.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ M6 progress now includes:
131131
- `internal/session`: indexed resume chains now distinguish budget-truncated parents from genuinely missing parents, exposing both markers while preserving bounded transcript reads.
132132
- `internal/session`: transcript full loader and line-offset index now accept entry type aliases such as `role`, `entry_type`, and `messageType`, plus `createdAt`/`created_at` timestamp aliases.
133133
- `internal/contracts`/`internal/session`: transcript resume now accepts nested content block aliases for `toolUseId`/`toolUseID`, `isError`, `cacheControl`, `cacheReference`, and cache edit `cacheReference`; tool-use content block IDs also accept JSON numbers and preserve them as strings.
134-
- `internal/contracts`/`internal/session`: nested contract message `content` now accepts string text and single content-block objects, normalizing string content into text blocks and accepting `role`/`messageType` as message type aliases.
134+
- `internal/contracts`/`internal/session`: nested contract message content now accepts string text, single content-block objects, and mixed string/content-block arrays; string content is normalized into text blocks, while `text`/`body`/`message`/`value`/`output` body aliases and `role`/`messageType` type aliases are preserved.
135135
- `internal/contracts`/`internal/session`: nested contract messages now accept parent aliases such as `parentUUID`, `parentId`/`parentID`/`parent_id`, `parentMessageId`/`parentMessageID`/`parent_message_id`, and parent-message UUID aliases, preserving payload-supplied parents in transcript and remote-history resume paths.
136136
- `internal/contracts`/`internal/session`: nested contract messages now accept `messageId`/`messageID`/`message_id` and `messageUuid`/`messageUUID`/`message_uuid` as their own ID/UUID aliases, and primary `id` accepts JSON numbers, preserving payload-supplied nested message IDs in indexed resume output.
137137
- `internal/contracts`/`internal/session`: the base `SessionEntry` JSONL loader now accepts role/type, message ID/UUID, parent ID/UUID, and session ID/UUID aliases so older entry files keep type, parent, and session metadata when loaded through `session.Load`.

internal/contracts/messages.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,11 @@ func (m *Message) UnmarshalJSON(data []byte) error {
216216
IsMeta bool `json:"isMeta"`
217217
Timestamp string `json:"timestamp"`
218218
Content json.RawMessage `json:"content"`
219+
Text json.RawMessage `json:"text"`
220+
Body json.RawMessage `json:"body"`
221+
MessageText json.RawMessage `json:"message"`
222+
Value json.RawMessage `json:"value"`
223+
Output json.RawMessage `json:"output"`
219224
Subtype string `json:"subtype"`
220225
Model string `json:"model"`
221226
Usage *Usage `json:"usage"`
@@ -251,6 +256,9 @@ func (m *Message) UnmarshalJSON(data []byte) error {
251256
if err != nil {
252257
return err
253258
}
259+
if len(content) == 0 && isEmptyJSONRaw(aux.Content) {
260+
content = textContentBlocksFromRaw(aux.Text, aux.Body, aux.MessageText, aux.Value, aux.Output)
261+
}
254262
*m = Message{
255263
ID: string(aux.ID),
256264
Type: aux.Type,
@@ -319,7 +327,7 @@ func (m *Message) UnmarshalJSON(data []byte) error {
319327

320328
func contentBlocksFromRaw(raw json.RawMessage) ([]ContentBlock, error) {
321329
raw = bytes.TrimSpace(raw)
322-
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
330+
if isEmptyJSONRaw(raw) {
323331
return nil, nil
324332
}
325333
switch raw[0] {
@@ -335,6 +343,20 @@ func contentBlocksFromRaw(raw json.RawMessage) ([]ContentBlock, error) {
335343
return nil, err
336344
}
337345
return []ContentBlock{block}, nil
346+
case '[':
347+
var items []json.RawMessage
348+
if err := json.Unmarshal(raw, &items); err != nil {
349+
return nil, err
350+
}
351+
blocks := make([]ContentBlock, 0, len(items))
352+
for _, item := range items {
353+
next, err := contentBlocksFromRaw(item)
354+
if err != nil {
355+
return nil, err
356+
}
357+
blocks = append(blocks, next...)
358+
}
359+
return blocks, nil
338360
default:
339361
var blocks []ContentBlock
340362
if err := json.Unmarshal(raw, &blocks); err != nil {
@@ -344,6 +366,25 @@ func contentBlocksFromRaw(raw json.RawMessage) ([]ContentBlock, error) {
344366
}
345367
}
346368

369+
func textContentBlocksFromRaw(values ...json.RawMessage) []ContentBlock {
370+
for _, value := range values {
371+
value = bytes.TrimSpace(value)
372+
if isEmptyJSONRaw(value) || value[0] != '"' {
373+
continue
374+
}
375+
var text string
376+
if err := json.Unmarshal(value, &text); err == nil {
377+
return []ContentBlock{NewTextBlock(text)}
378+
}
379+
}
380+
return nil
381+
}
382+
383+
func isEmptyJSONRaw(raw json.RawMessage) bool {
384+
raw = bytes.TrimSpace(raw)
385+
return len(raw) == 0 || bytes.Equal(raw, []byte("null"))
386+
}
387+
347388
func firstMessageType(values ...string) MessageType {
348389
for _, value := range values {
349390
switch MessageType(strings.ToLower(strings.TrimSpace(value))) {

internal/contracts/messages_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,36 @@ func TestMessageUnmarshalAcceptsSingleContentBlock(t *testing.T) {
3030
t.Fatalf("content = %#v", message.Content)
3131
}
3232
}
33+
34+
func TestMessageUnmarshalAcceptsMixedStringContentArray(t *testing.T) {
35+
var message Message
36+
if err := json.Unmarshal([]byte(`{"type":"assistant","content":["hello",{"type":"text","text":" world"}]}`), &message); err != nil {
37+
t.Fatal(err)
38+
}
39+
if len(message.Content) != 2 ||
40+
message.Content[0].Type != ContentText || message.Content[0].Text != "hello" ||
41+
message.Content[1].Type != ContentText || message.Content[1].Text != " world" {
42+
t.Fatalf("content = %#v", message.Content)
43+
}
44+
}
45+
46+
func TestMessageUnmarshalAcceptsTextBodyAliases(t *testing.T) {
47+
for name, raw := range map[string]string{
48+
"text": `{"role":"assistant","text":"from text"}`,
49+
"body": `{"role":"assistant","body":"from body"}`,
50+
"message": `{"role":"assistant","message":"from message"}`,
51+
"value": `{"role":"assistant","value":"from value"}`,
52+
"output": `{"role":"assistant","output":"from output"}`,
53+
"null": `{"role":"assistant","content":null,"text":"from null fallback"}`,
54+
} {
55+
t.Run(name, func(t *testing.T) {
56+
var message Message
57+
if err := json.Unmarshal([]byte(raw), &message); err != nil {
58+
t.Fatal(err)
59+
}
60+
if message.Type != MessageAssistant || len(message.Content) != 1 || message.Content[0].Text == "" {
61+
t.Fatalf("message = %#v", message)
62+
}
63+
})
64+
}
65+
}

internal/session/remote_history_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,8 +1075,8 @@ func TestRemoteHistoryTranscriptMessagesAcceptsParentIDAliases(t *testing.T) {
10751075
func TestRemoteHistoryTranscriptMessagesAcceptsEventPayloadAliases(t *testing.T) {
10761076
var response sessionEventsResponse
10771077
if err := json.Unmarshal([]byte(`{"events":[
1078-
{"eventType":"user","eventId":"evt_u1","sessionID":"s_payload","createdAt":"2026-01-01T00:00:01Z","payload":{"role":"user","content":"hi"}},
1079-
{"event_type":"assistant","event_id":"evt_a1","session_id":"s_payload","parentMessageId":"evt_u1","created_at":"2026-01-01T00:00:02Z","body":{"role":"assistant","content":"hello"}}
1078+
{"eventType":"user","eventId":"evt_u1","sessionID":"s_payload","createdAt":"2026-01-01T00:00:01Z","payload":{"role":"user","text":"hi"}},
1079+
{"event_type":"assistant","event_id":"evt_a1","session_id":"s_payload","parentMessageId":"evt_u1","created_at":"2026-01-01T00:00:02Z","body":{"role":"assistant","message":"hello"}}
10801080
]}`), &response); err != nil {
10811081
t.Fatal(err)
10821082
}

0 commit comments

Comments
 (0)