Skip to content

Commit 33a0923

Browse files
author
SqlRush
committed
Accept included remote history events
1 parent 1bc036b commit 33a0923

5 files changed

Lines changed: 115 additions & 9 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ M6 补充:remote history event-list 现在接受 `value`/`values`/`resources`/
112112

113113
M6 补充:remote history response parser 现在会解包 `payload`/`response`/`result`/`body` 等通用响应外壳,外壳内的 event list、pagination、links 会继续递归解析。
114114

115+
M6 补充:remote history response parser 现在接受 JSON:API `included` collection,会过滤非事件资源,并递归解包 `resource`/`attributes`/`properties` 后用外层 resource id 作为事件 ID fallback。
116+
115117
M6 补充:remote history `data`/`payload`/`response`/`result`/`body` 等 event-list 字段现在也接受单个 SDK event 对象,避免非数组单事件页被当作普通 wrapper 后丢失。
116118

117119
M6 补充:remote history `SDKEvent` 本体现在接受 `eventType`/`event_type`/`role` 类型别名、`createdAt`/`created_at` 时间戳别名,以及 `payload`/`data`/`body`/`serializedMessage` message payload 别名;payload 只有 `role`/`content` 时也能 materialize 成 transcript message。

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ test/parity/ # golden tests against TS/official behavior
208208
- 本轮补充:remote history response parser 会递归解包 `data.session.events``data.projectSession.eventConnection``data.viewer.session.events``data.node.eventConnection``conversation``remoteHistory``_embedded` 等 GraphQL/session/HAL wrapper,继续复用 `nodes`/`edges[].node``pageInfo` pagination 解析。
209209
- 本轮补充:remote history event-list 接受 `value`/`values`/`resources`/`collection` 别名,connection edge 也接受 `resource`/`value` 作为 node payload,覆盖 OData/HAL/resource collection 风格响应。
210210
- 本轮补充:remote history response parser 现在也递归解包页级 JSON:API/resource `attributes`/`properties` wrapper,event-list 接受 `list`/`object`/`objects` aliases,并能把单个 `data.attributes` resource event 作为一条 SDK event 恢复。
211+
- 本轮补充:remote history response parser 现在也接受 JSON:API `included` collection,会过滤非事件资源,并递归解包 `resource`/`attributes`/`properties` 后保留外层 resource id 作为事件 ID fallback。
211212
- 本轮补充:remote history response parser 会解包 `payload`/`response`/`result`/`body` 等通用响应外壳,外壳内的 event list、pagination、links 会继续递归解析。
212213
- 本轮补充:remote history `SDKEvent` 本体接受 `eventType`/`event_type`/`role` 类型别名、`createdAt`/`created_at` 时间戳别名,以及 `payload`/`data`/`body`/`serializedMessage` message payload 别名;payload 只有 `role`/`content` 时也能 materialize 成 transcript message。
213214
- 本轮补充:transcript resume 的嵌套 content block 接受 `toolUseId`/`toolUseID``isError``cacheControl``cacheReference` 字段别名,并保留 cache edit 的 `cacheReference`

docs/first-second-parity-audit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ M7 progress now includes:
361361
- `internal/session`: remote-history event arrays now unwrap element-level wrappers such as `event`/`record`/`entry`/`item`/`resource`/`value` and wrapper-only `data`/`payload`/`body`, using element cursors as event ID fallback outside GraphQL edges.
362362
- `internal/session`: remote-history event arrays now accept JSON:API/resource-style elements with event payloads inside `attributes` or `properties`, preserving the outer resource `id` as the SDK event ID fallback.
363363
- `internal/session`: remote-history response parsing now recurses through page-level JSON:API/resource `attributes`/`properties` wrappers, accepts `list`/`object`/`objects` as event-list aliases, and treats a single `data.attributes` resource payload as one SDK event.
364+
- `internal/session`: remote-history response parsing now accepts JSON:API `included` collections, filters out non-event resources, and recursively unwraps resource/attributes/properties event payloads while preserving resource ID fallbacks.
364365
- `internal/contracts`/`internal/session`: content block `type` decoding now normalizes camel/kebab/compact aliases such as `toolUse`, `tool-result`, `cacheEdits`, `inputImage`, and `chain-of-thought`.
365366
- `internal/session`: image history dimensions now default display size to original size when only `width`/`height` or original dimensions are present, preserving source metadata for single-size image fixtures.
366367
- `internal/session`: pasted-content history type decoding now normalizes aliases such as `inputImage`/`pasted-image`/`input_text`/`pasted-text` to canonical `image`/`text` for runtime and stored history.

internal/session/remote_history.go

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ type sessionEventsResponse struct {
6565
History []contracts.SDKEvent `json:"history"`
6666
Nodes []contracts.SDKEvent `json:"nodes"`
6767
Edges []contracts.SDKEvent `json:"edges"`
68+
Included []contracts.SDKEvent `json:"included"`
6869
HasMore bool `json:"has_more"`
6970
HasMoreCamel bool `json:"hasMore"`
7071
HasNext bool `json:"has_next"`
@@ -204,6 +205,15 @@ func (r *sessionEventsResponse) mergeJSON(data []byte) error {
204205
return err
205206
}
206207
}
208+
if value, ok := raw["included"]; ok {
209+
events, ok, err := decodeRemoteHistoryFilteredEventArray("included", value)
210+
if err != nil {
211+
return err
212+
}
213+
if ok && r.Included == nil {
214+
r.Included = events
215+
}
216+
}
207217
return nil
208218
}
209219

@@ -494,6 +504,9 @@ func (r *sessionEventsResponse) mergeFrom(other sessionEventsResponse) {
494504
if r.Edges == nil {
495505
r.Edges = other.Edges
496506
}
507+
if r.Included == nil {
508+
r.Included = other.Included
509+
}
497510
r.mergePageFields(other)
498511
}
499512

@@ -678,6 +691,35 @@ func decodeRemoteHistoryEventArray(name string, data json.RawMessage) ([]contrac
678691
return events, nil
679692
}
680693

694+
func decodeRemoteHistoryFilteredEventArray(name string, data json.RawMessage) ([]contracts.SDKEvent, bool, error) {
695+
data = bytes.TrimSpace(data)
696+
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
697+
return nil, false, nil
698+
}
699+
if data[0] != '[' {
700+
return nil, false, fmt.Errorf("%s must be an event array", name)
701+
}
702+
var rawEvents []json.RawMessage
703+
if err := json.Unmarshal(data, &rawEvents); err != nil {
704+
return nil, false, fmt.Errorf("%s: %w", name, err)
705+
}
706+
events := make([]contracts.SDKEvent, 0, len(rawEvents))
707+
for index, rawEvent := range rawEvents {
708+
if !remoteHistoryRawLooksLikeEvent(rawEvent) {
709+
continue
710+
}
711+
event, err := decodeRemoteHistoryEventElement(name, index, rawEvent)
712+
if err != nil {
713+
return nil, false, err
714+
}
715+
events = append(events, event)
716+
}
717+
if len(events) == 0 {
718+
return nil, false, nil
719+
}
720+
return events, true, nil
721+
}
722+
681723
func decodeRemoteHistoryEventMap(name string, data json.RawMessage) ([]contracts.SDKEvent, bool, error) {
682724
var rawEvents map[string]json.RawMessage
683725
if err := json.Unmarshal(data, &rawEvents); err != nil {
@@ -722,7 +764,7 @@ func decodeRemoteHistoryEventMap(name string, data json.RawMessage) ([]contracts
722764
func remoteHistoryEventMapReservedKey(key string) bool {
723765
switch key {
724766
case "page", "pagination", "page_info", "pageInfo", "paging", "links", "_links", "meta", "metadata",
725-
"attributes", "properties",
767+
"attributes", "properties", "included",
726768
"has_more", "hasMore", "has_next", "hasNext", "has_next_page", "hasNextPage",
727769
"has_previous", "hasPrevious", "has_previous_page", "hasPreviousPage", "has_older", "hasOlder", "more",
728770
"first_id", "firstId", "next_before_id", "nextBeforeId", "next_cursor", "nextCursor",
@@ -741,6 +783,24 @@ func remoteHistoryEventMapReservedKey(key string) bool {
741783
}
742784
}
743785

786+
func remoteHistoryRawLooksLikeEvent(data json.RawMessage) bool {
787+
data = bytes.TrimSpace(data)
788+
if len(data) == 0 || bytes.Equal(data, []byte("null")) || data[0] != '{' {
789+
return false
790+
}
791+
var fields map[string]json.RawMessage
792+
if err := json.Unmarshal(data, &fields); err != nil {
793+
return false
794+
}
795+
if remoteHistoryLooksLikeSingleEvent(fields) {
796+
return true
797+
}
798+
if nested := remoteHistoryWrappedEventRaw(fields); nested != nil {
799+
return remoteHistoryRawLooksLikeEvent(nested)
800+
}
801+
return false
802+
}
803+
744804
func decodeRemoteHistoryEventElement(name string, index int, data json.RawMessage) (contracts.SDKEvent, error) {
745805
data = bytes.TrimSpace(data)
746806
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
@@ -749,15 +809,22 @@ func decodeRemoteHistoryEventElement(name string, index int, data json.RawMessag
749809
cursor := ""
750810
resourceID := ""
751811
rawEvent := data
752-
if data[0] == '{' {
812+
for len(rawEvent) > 0 && rawEvent[0] == '{' {
753813
var fields map[string]json.RawMessage
754-
if err := json.Unmarshal(data, &fields); err == nil {
755-
cursor = remoteHistoryStringField(fields, "cursor")
756-
resourceID = remoteHistoryStringField(fields, "id", "event_id", "eventId", "uuid")
757-
if nested := remoteHistoryWrappedEventRaw(fields); nested != nil {
758-
rawEvent = nested
759-
}
814+
if err := json.Unmarshal(rawEvent, &fields); err != nil {
815+
break
816+
}
817+
cursor = firstNonEmpty(cursor, remoteHistoryStringField(fields, "cursor"))
818+
resourceID = firstNonEmpty(resourceID, remoteHistoryStringField(fields, "id", "event_id", "eventId", "uuid"))
819+
nested := remoteHistoryWrappedEventRaw(fields)
820+
if nested == nil {
821+
break
822+
}
823+
nested = bytes.TrimSpace(nested)
824+
if len(nested) == 0 || bytes.Equal(nested, rawEvent) {
825+
break
760826
}
827+
rawEvent = nested
761828
}
762829
var event contracts.SDKEvent
763830
if err := json.Unmarshal(rawEvent, &event); err != nil {
@@ -1391,7 +1458,7 @@ func firstEventList(values ...[]contracts.SDKEvent) []contracts.SDKEvent {
13911458
}
13921459

13931460
func responseEventList(response sessionEventsResponse) []contracts.SDKEvent {
1394-
return firstEventList(response.Data, response.Events, response.Items, response.Results, response.Value, response.Values, response.Resources, response.Collection, response.Records, response.Rows, response.Entries, response.Messages, response.History, response.Nodes, response.Edges)
1461+
return firstEventList(response.Data, response.Events, response.Items, response.Results, response.Value, response.Values, response.Resources, response.Collection, response.Records, response.Rows, response.Entries, response.Messages, response.History, response.Nodes, response.Edges, response.Included)
13951462
}
13961463

13971464
func cloneHeader(header http.Header) http.Header {

internal/session/remote_history_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,41 @@ func TestFetchRemoteHistoryAcceptsResourcePageAttributes(t *testing.T) {
988988
}
989989
}
990990

991+
func TestFetchRemoteHistoryAcceptsIncludedResourceEvents(t *testing.T) {
992+
var seen []url.Values
993+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
994+
seen = append(seen, r.URL.Query())
995+
w.Header().Set("Content-Type", "application/json")
996+
_, _ = w.Write([]byte(`{
997+
"included": [
998+
{"id":"tool_1","type":"tool","attributes":{"name":"Bash"}},
999+
{"id":"evt_included_status","type":"session-events","attributes":{"eventType":"status","sessionID":"s","status":"included"}},
1000+
{"resource":{"id":"evt_included_assistant","type":"session-events","properties":{"role":"assistant","sessionId":"s","createdAt":"2026-01-01T00:00:02Z","message":{"type":"assistant","content":[{"type":"text","text":"hello included"}]}}}}
1001+
],
1002+
"pageInfo": {"hasPreviousPage": false}
1003+
}`))
1004+
}))
1005+
defer server.Close()
1006+
1007+
authCtx := NewRemoteHistoryAuthContext("s", "token", "", auth.OAuthConfig{BaseAPIURL: server.URL})
1008+
events, err := FetchRemoteHistory(context.Background(), server.Client(), authCtx, RemoteHistoryFetchOptions{Limit: 3})
1009+
if err != nil {
1010+
t.Fatal(err)
1011+
}
1012+
if !events.Complete || events.Pages != 1 || len(events.Events) != 2 || events.NextBeforeID != "" {
1013+
t.Fatalf("events = %#v", events)
1014+
}
1015+
if events.Events[0].ID != "evt_included_status" || events.Events[0].Status != "included" || events.Events[0].SessionID != "s" {
1016+
t.Fatalf("status included event = %#v", events.Events[0])
1017+
}
1018+
if events.Events[1].ID != "evt_included_assistant" || events.Events[1].Type != contracts.SDKEventAssistant || events.Events[1].Message == nil || len(events.Events[1].Message.Content) != 1 || events.Events[1].Message.Content[0].Text != "hello included" {
1019+
t.Fatalf("assistant included event = %#v", events.Events[1])
1020+
}
1021+
if len(seen) != 1 {
1022+
t.Fatalf("queries = %#v", seen)
1023+
}
1024+
}
1025+
9911026
func TestFetchRemoteHistoryAcceptsKeyedEventMaps(t *testing.T) {
9921027
var seen []url.Values
9931028
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)