Skip to content

Commit 992b5c2

Browse files
author
SqlRush
committed
Recover visible sidechain summaries
1 parent 0d89959 commit 992b5c2

5 files changed

Lines changed: 237 additions & 3 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ test/parity/ # golden tests against TS/official behavior
220220
- 本轮补充:remote history pagination 现在接受 `starting_after`/`startingAfter`/`after*` cursor aliases,page 字段和 link URL query 都可驱动下一页 `before_id` 续抓。
221221
- 本轮补充:remote history `SDKEvent` 本体接受 `eventType`/`event_type`/`role` 类型别名、`createdAt`/`created_at` 时间戳别名,以及 `payload`/`data`/`body`/`metadata`/`meta`/`attributes`/`properties`/`serializedMessage` message payload 别名;payload 只有 `role`/`content` 时也能 materialize 成 transcript message。
222222
- 本轮补充:sidechain lifecycle 读取现在接受运行时相邻字段别名,包括 `jobId`/`threadId`/`workflowId`/`operationId`/`requestId``workerType`/`taskType``workspaceRoot`/`projectPath``instructions`/`operationName`/`commandName`/`displayTitle``jobStatus``resultState``outputText` 和毫秒级 start/end time 字段,并会递归解包 `runtime`/`context`/`state` 容器。
223+
- 本轮补充:sidechain lifecycle 和 metadata sidecar 的 task description/summary 读取现在接受 visible text payload,包括 text content block、message object、content block array、provider-style `message.content`/`parts`/`outputText` wrapper,并继续跳过 thinking/tool/image 等非可见块。
223224
- 本轮补充:transcript resume 的嵌套 content block 接受 `toolUseId`/`toolUseID``isError``cacheControl``cacheReference` 字段别名,并保留 cache edit 的 `cacheReference`
224225
- 本轮补充:transcript resume 的 nested content block `id`/`tool_use_id`/`toolUseId` 现在接受 JSON number,并保留为字符串 tool-use ID。
225226
- 本轮补充:嵌套 contract message 的 `content` 接受字符串、单个 content-block 对象,以及混合字符串/content-block 数组;字符串会归一为 text block,并接受 `text`/`body`/`message`/`value`/`output` 正文字段和 `role`/`messageType` 类型别名,提升 transcript/remote history payload 恢复率。

docs/first-second-parity-audit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ M6 progress now includes:
207207
- `internal/session`: sidechain lifecycle field extraction now also traverses collection wrappers such as `edges`, `nodes`, and `included`, plus array elements, so GraphQL connection or JSON:API included start/summary payloads can still recover state/list/resume fields.
208208
- `internal/session`: sidechain lifecycle scalar fields now accept JSON number values for IDs and other string-like fields, preserving numeric subagent IDs as strings across state/list/resume recovery.
209209
- `internal/session`: sidechain lifecycle and metadata sidecar loading now accept adjacent runtime aliases such as `jobId`, `threadId`, `workflowId`, `operationId`, `requestId`, `workerType`, `taskType`, `workspaceRoot`, `projectPath`, `instructions`, `operationName`, `commandName`, `displayTitle`, `jobStatus`, `resultState`, `outputText`, and millisecond start/end time fields under `runtime`/`context`/`state` or JSON:API/resource wrappers.
210+
- `internal/session`: sidechain lifecycle and metadata sidecar summary/description loading now accepts visible text payloads, including text content blocks, message objects, content block arrays, and provider-style `message.content`/`parts`/`outputText` wrappers while ignoring hidden thinking/tool/image blocks.
210211
- `internal/session`: sidechain runtime finish now canonicalizes status aliases such as `success`, `error`, and `canceled` before writing summary records, keeping sidechain and main transcript lifecycle output stable.
211212
- `internal/session`: sidechain runtime now rejects duplicate starts while the same sidechain ID is running, and state loading treats a later start as a fresh lifecycle by clearing stale summary/end metadata.
212213
- `internal/session`: sidechain lifecycle state loading now accepts adjacent subtype aliases such as `subagent_started`, `agentStarted`, `task_failed`, and `sidechainCompleted`, with broader task/worker/run ID fields plus `resultText`/`finalMessage` summaries and subtype-derived failed/cancelled defaults.

internal/session/sidechain_metadata.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ func (m *SidechainMetadata) UnmarshalJSON(data []byte) error {
2626
metadata.AgentType = topLevelStringField(fields, "type")
2727
}
2828
metadata.WorktreePath = firstStringField(fields, sidechainLifecycleWorktreeFields...)
29-
metadata.Description = firstStringField(fields, sidechainLifecycleDescriptionFields...)
29+
metadata.Description = firstTextField(fields, sidechainLifecycleDescriptionFields...)
3030
*m = metadata
3131
return nil
3232
}

internal/session/sidechain_status.go

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"unicode"
1111

1212
"ccgo/internal/contracts"
13+
msgs "ccgo/internal/messages"
1314
)
1415

1516
type SidechainState struct {
@@ -198,7 +199,7 @@ func LoadSidechainState(info SidechainInfo) (SidechainState, error) {
198199
if worktreePath := firstStringField(msg.Content, sidechainLifecycleWorktreeFields...); worktreePath != "" && state.Metadata.WorktreePath == "" {
199200
state.Metadata.WorktreePath = worktreePath
200201
}
201-
if description := firstStringField(msg.Content, sidechainLifecycleDescriptionFields...); description != "" && state.Metadata.Description == "" {
202+
if description := firstTextField(msg.Content, sidechainLifecycleDescriptionFields...); description != "" && state.Metadata.Description == "" {
202203
state.Metadata.Description = description
203204
}
204205
if status := sidechainStatusField(msg.Content, sidechainLifecycleStartStatusFields...); status != "" {
@@ -220,7 +221,7 @@ func LoadSidechainState(info SidechainInfo) (SidechainState, error) {
220221
} else {
221222
state.Status = SidechainStatusCompleted
222223
}
223-
state.Summary = firstStringField(msg.Content, sidechainLifecycleSummaryFields...)
224+
state.Summary = firstTextField(msg.Content, sidechainLifecycleSummaryFields...)
224225
}
225226
}
226227
if state.Status == SidechainStatusUnknown {
@@ -411,6 +412,10 @@ func firstStringField(value any, keys ...string) string {
411412
return firstStringFieldDepth(value, keys, 0)
412413
}
413414

415+
func firstTextField(value any, keys ...string) string {
416+
return firstTextFieldDepth(value, keys, 0)
417+
}
418+
414419
func firstStringFieldDepth(value any, keys []string, depth int) string {
415420
if depth > 4 {
416421
return ""
@@ -448,6 +453,137 @@ func firstStringFieldDepth(value any, keys []string, depth int) string {
448453
return ""
449454
}
450455

456+
func firstTextFieldDepth(value any, keys []string, depth int) string {
457+
if depth > 4 {
458+
return ""
459+
}
460+
switch fields := value.(type) {
461+
case map[string]any:
462+
for _, key := range keys {
463+
if raw, ok := fields[key]; ok {
464+
if value := visibleTextField(raw); value != "" {
465+
return value
466+
}
467+
}
468+
}
469+
for _, key := range []string{"payload", "data", "body", "content", "result", "response", "record", "records", "entry", "entries", "item", "items", "event", "events", "edge", "edges", "node", "nodes", "resource", "resources", "attributes", "properties", "attrs", "metadata", "details", "runtime", "context", "state", "value", "values", "output", "outputs", "included", "collection", "list", "children"} {
470+
if raw, ok := fields[key]; ok {
471+
if value := firstTextFieldDepth(raw, keys, depth+1); value != "" {
472+
return value
473+
}
474+
}
475+
}
476+
case []any:
477+
for _, item := range fields {
478+
if value := firstTextFieldDepth(item, keys, depth+1); value != "" {
479+
return value
480+
}
481+
}
482+
case map[string]string:
483+
for _, key := range keys {
484+
if raw := fields[key]; raw != "" {
485+
return raw
486+
}
487+
}
488+
default:
489+
}
490+
return ""
491+
}
492+
493+
func visibleTextField(value any) string {
494+
if value == nil {
495+
return ""
496+
}
497+
if value := scalarStringField(value); value != "" {
498+
return value
499+
}
500+
if text := visibleContentBlockText(value); text != "" {
501+
return text
502+
}
503+
if nonVisibleContentBlock(value) {
504+
return ""
505+
}
506+
if text := visibleMessageText(value); text != "" {
507+
return text
508+
}
509+
switch raw := value.(type) {
510+
case map[string]any:
511+
for _, key := range []string{
512+
"summary", "summaryText", "summary_text", "finalSummary", "final_summary",
513+
"resultSummary", "result_summary", "resultText", "result_text",
514+
"finalMessage", "final_message", "completion", "completionText", "completion_text",
515+
"outputText", "output_text", "messageText", "message_text",
516+
"body", "text", "message", "content", "parts", "segments", "output", "value",
517+
"detail", "details", "description",
518+
} {
519+
if nested, ok := raw[key]; ok {
520+
if text := visibleTextField(nested); text != "" {
521+
return text
522+
}
523+
}
524+
}
525+
case []any:
526+
parts := make([]string, 0, len(raw))
527+
for _, item := range raw {
528+
if text := visibleTextField(item); text != "" {
529+
parts = append(parts, text)
530+
}
531+
}
532+
return strings.Join(parts, "\n")
533+
case map[string]string:
534+
for _, key := range []string{"text", "body", "message", "content", "value", "output", "summary", "description", "details"} {
535+
if text := raw[key]; text != "" {
536+
return text
537+
}
538+
}
539+
}
540+
return ""
541+
}
542+
543+
func visibleContentBlockText(value any) string {
544+
data, err := json.Marshal(value)
545+
if err != nil {
546+
return ""
547+
}
548+
var block contracts.ContentBlock
549+
if err := json.Unmarshal(data, &block); err != nil {
550+
return ""
551+
}
552+
if block.Type == contracts.ContentText && block.Text != "" {
553+
return block.Text
554+
}
555+
return ""
556+
}
557+
558+
func nonVisibleContentBlock(value any) bool {
559+
data, err := json.Marshal(value)
560+
if err != nil {
561+
return false
562+
}
563+
var block contracts.ContentBlock
564+
if err := json.Unmarshal(data, &block); err != nil {
565+
return false
566+
}
567+
switch block.Type {
568+
case contracts.ContentThinking, contracts.ContentToolUse, contracts.ContentToolResult, contracts.ContentImage, contracts.ContentCacheEdits:
569+
return true
570+
default:
571+
return false
572+
}
573+
}
574+
575+
func visibleMessageText(value any) string {
576+
data, err := json.Marshal(value)
577+
if err != nil {
578+
return ""
579+
}
580+
var message contracts.Message
581+
if err := json.Unmarshal(data, &message); err != nil {
582+
return ""
583+
}
584+
return msgs.TextContent(message)
585+
}
586+
451587
func scalarStringField(value any) string {
452588
switch raw := value.(type) {
453589
case string:

internal/session/sidechain_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,63 @@ func TestLoadSidechainStateAcceptsWrappedLifecycleContent(t *testing.T) {
493493
}
494494
}
495495

496+
func TestLoadSidechainStateAcceptsVisibleTextLifecyclePayloads(t *testing.T) {
497+
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
498+
sessionID := contracts.ID("sess_1")
499+
if err := AppendSidechainMessage(sessionPath, sessionID, "text-payload", TranscriptMessage{
500+
Type: "system",
501+
UUID: "start_1",
502+
Timestamp: "2026-01-01T00:00:01Z",
503+
Subtype: "subagent_started",
504+
Content: map[string]any{
505+
"payload": map[string]any{
506+
"subagentId": "text_worker",
507+
"agentName": "writer",
508+
"workspacePath": "/tmp/text-worktree",
509+
"taskDescription": map[string]any{
510+
"type": "text",
511+
"text": "write visible payload support",
512+
},
513+
"status": "running",
514+
},
515+
},
516+
}); err != nil {
517+
t.Fatal(err)
518+
}
519+
if err := AppendSidechainMessage(sessionPath, sessionID, "text-payload", TranscriptMessage{
520+
Type: "system",
521+
UUID: "summary_1",
522+
Timestamp: "2026-01-01T00:00:02Z",
523+
Subtype: "agent_finish",
524+
Content: map[string]any{
525+
"response": map[string]any{
526+
"agentID": "text_worker",
527+
"outcome": "completedSuccessfully",
528+
"message": map[string]any{
529+
"role": "assistant",
530+
"content": []any{
531+
map[string]any{"type": "text", "text": "visible provider summary"},
532+
map[string]any{"type": "thinking", "text": "hidden chain of thought"},
533+
},
534+
},
535+
},
536+
},
537+
}); err != nil {
538+
t.Fatal(err)
539+
}
540+
541+
state, err := FindSidechainState(sessionPath, sessionID, "text-payload")
542+
if err != nil {
543+
t.Fatal(err)
544+
}
545+
if state.ID != "text_worker" || state.Status != SidechainStatusCompleted || state.Summary != "visible provider summary" {
546+
t.Fatalf("state = %#v", state)
547+
}
548+
if state.Metadata.AgentType != "writer" || state.Metadata.WorktreePath != "/tmp/text-worktree" || state.Metadata.Description != "write visible payload support" {
549+
t.Fatalf("metadata = %#v", state.Metadata)
550+
}
551+
}
552+
496553
func TestLoadSidechainStateAcceptsResourceLifecycleAttributes(t *testing.T) {
497554
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
498555
sessionID := contracts.ID("sess_1")
@@ -833,6 +890,45 @@ func TestLoadSidechainStateAcceptsWrappedMetadataSidecarAliases(t *testing.T) {
833890
}
834891
}
835892

893+
func TestLoadSidechainStateAcceptsVisibleTextMetadataSidecarAliases(t *testing.T) {
894+
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
895+
sessionID := contracts.ID("sess_1")
896+
if err := AppendSidechainMessage(sessionPath, sessionID, "text_meta", TranscriptMessage{
897+
Type: "assistant",
898+
UUID: "msg_1",
899+
}); err != nil {
900+
t.Fatal(err)
901+
}
902+
metadataPath := SidechainMetadataPath(sessionPath, sessionID, "text_meta")
903+
if err := os.WriteFile(metadataPath, []byte(`{
904+
"resource": {
905+
"id": "meta_2",
906+
"type": "sidechain-metadata",
907+
"attributes": {
908+
"agentName": "summarizer",
909+
"workspaceRoot": "/tmp/summary-worktree",
910+
"description": {
911+
"role": "assistant",
912+
"content": [
913+
{"type": "text", "text": "summarize wrapped text metadata"},
914+
{"type": "thinking", "text": "hidden"}
915+
]
916+
}
917+
}
918+
}
919+
}`), 0o644); err != nil {
920+
t.Fatal(err)
921+
}
922+
923+
state, err := FindSidechainState(sessionPath, sessionID, "text_meta")
924+
if err != nil {
925+
t.Fatal(err)
926+
}
927+
if state.Metadata.AgentType != "summarizer" || state.Metadata.WorktreePath != "/tmp/summary-worktree" || state.Metadata.Description != "summarize wrapped text metadata" {
928+
t.Fatalf("metadata = %#v", state.Metadata)
929+
}
930+
}
931+
836932
func TestSidechainManagerOrchestratesRunningSidechains(t *testing.T) {
837933
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
838934
sessionID := contracts.ID("sess_1")

0 commit comments

Comments
 (0)