Skip to content

Commit 7785981

Browse files
author
SqlRush
committed
Accept wrapped prompt history records
1 parent 6a80cdf commit 7785981

5 files changed

Lines changed: 249 additions & 36 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,7 @@ M7 补充:prompt history `LogEntry` 读取现在接受 `sessionID`/`session`/`
482482

483483
本轮补充:prompt history 与 interaction script 的 pasted-content ID 现在接受 `pastedContentId`/`attachmentID`/`contentID`/`imageID` 等别名,并容忍数字字符串,数组和单对象 attachment fixture 可保留原始 pasted-content ID。
484484

485-
本轮补充:prompt history 的 `pastedContents`/`pasted_contents` 现在除 map 外也接受 pasted-content 数组和单对象,runtime history 与 stored history 都会按内容内 ID/ID 别名重建 map。
485+
本轮补充:prompt history 的 `HistoryEntry`/`LogEntry` 以及 `pastedContents`/`pasted_contents` item 现在接受 `entry`/`record`/`item`/`payload` 等 wrapper;pasted contents 除 map 外也接受数组和单对象,runtime history 与 stored history 都会按内容内 ID/ID 别名重建 map。
486486

487487
本轮补充:prompt/history pasted-content 引用解析现在接受大小写差异和 `pasted image`/`input-image`/`input_text` 等占位符别名,文本展开、图片引用过滤和 next pasted ID seed 共用同一识别面。
488488

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ test/parity/ # golden tests against TS/official behavior
310310
- 本轮补充:reverse-search 现在按完整 `HistoryEntry` 匹配和选择历史,选中后恢复 text/image pasted-content metadata,后续 submit event 仍携带 display 与图片元数据。
311311
- 本轮补充:prompt history pasted-content 读取接受 `mimeType`/`mime_type`/`contentType``fileName`/`file_name`/`name``filePath`/`file_path`/`path` 等 text/image metadata 别名,使历史恢复路径和 image hint/parser metadata 兼容面一致。
312312
- 本轮补充:prompt history `LogEntry` 读取接受 `sessionID`/`session`/`sessionUuid`/`sessionUUID`/`session_uuid` 作为 session id 别名,current-session-first 历史排序不会因 session 字段拼写不同而失效。
313+
- 本轮补充:prompt history `HistoryEntry`/`LogEntry` 和 pasted-content item 现在接受 `entry`/`record`/`item`/`payload` 等 wrapper,stored history 可从 nested payload 读取显示文本/附件并用外层 project/session/timestamp metadata 补齐。
313314
- 本轮补充:REPL message restore 现在可从用户消息 content blocks、`imagePasteIds` 和 pasted-content metadata 重建 prompt text、`[Image #N]` 引用和 base64 image pasted contents。
314315
- 本轮补充:Ctrl-S prompt stash 现在保存/恢复 prompt text、cursor 和 pasted-content metadata,空 prompt 下再次触发会恢复 stash,贴近官方 `chat:stash`
315316
- 本轮补充:remote history REST/link 风格分页接受 `links`/`_links``next`/`previous`/`prev`/`older` 字符串 URL 或 `{href,url,uri,link}` 对象,并从 `before_id``beforeId``cursor``pageCursor``previousCursor``prevCursor``beforeCursor``olderCursor``startCursor``endCursor` 等 query 参数提取续抓 before-id。

docs/first-second-parity-audit.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ M7 progress now includes:
322322
- `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.
323323
- `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.
324324
- `internal/session`/`internal/tui`: pasted-content ID decoding now accepts aliases such as `pastedContentId`/`attachmentID`/`contentID`/`imageID` and numeric strings for runtime/stored history and script attachments.
325-
- `internal/session`: prompt-history `pastedContents`/`pasted_contents` now accepts map, list, and single-object shapes for runtime and stored history, rebuilding maps from content IDs and aliases.
325+
- `internal/session`: prompt-history `HistoryEntry`/`LogEntry` and `pastedContents`/`pasted_contents` items now accept wrappers such as `entry`/`record`/`item`/`payload`; pasted contents also accept map, list, and single-object shapes for runtime and stored history, rebuilding maps from content IDs and aliases.
326326
- `internal/session`/`internal/tui`: pasted-content reference parsing now accepts case differences and placeholders such as `pasted image`/`input-image`/`input_text`, sharing that recognition across text expansion, image filtering, and next pasted ID seeding.
327327
- `internal/session`: prompt-history `LogEntry` loading now accepts project aliases such as `projectPath`/`cwd`/`workingDirectory`/`workspacePath` and timestamp aliases such as `createdAt`/`unixTimestamp`, including RFC3339 timestamp normalization to Unix milliseconds.
328328
- `internal/session`: prompt-history display text loading now accepts aliases such as `prompt`/`text`/`input`/`content`/`value` for runtime and stored history.

internal/session/history_aliases.go

Lines changed: 194 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,18 @@ func (c *PastedContent) UnmarshalJSON(data []byte) error {
5959
if err := json.Unmarshal(data, &fields); err != nil {
6060
return err
6161
}
62-
*c = PastedContent{
63-
ID: historyIntJSONField(fields, historyIDFieldNames()...),
64-
Type: canonicalPastedContentType(historyStringJSONField(fields, "type", "kind", "pastedType", "pasted_type")),
65-
Content: historyStringJSONField(fields, "content", "value", "data", "base64"),
66-
MediaType: historyStringJSONField(fields, "mediaType", "media_type", "mimeType", "mime_type", "contentType", "content_type"),
67-
Filename: historyStringJSONField(fields, "filename", "fileName", "file_name", "name"),
68-
Dimensions: historyDimensionsJSONField(fields, "dimensions", "imageDimensions", "image_dimensions"),
69-
SourcePath: historyStringJSONField(fields, "sourcePath", "source_path", "filePath", "file_path", "path"),
62+
if nested, ok := historyWrappedPayloadJSON(fields, historyPastedContentWrapperFieldNames(), historyPastedContentValueFieldNames(), nil); ok {
63+
var content PastedContent
64+
if err := json.Unmarshal(nested, &content); err != nil {
65+
return err
66+
}
67+
historyApplyPastedContentFields(&content, fields, false)
68+
*c = content
69+
return nil
7070
}
71+
var content PastedContent
72+
historyApplyPastedContentFields(&content, fields, true)
73+
*c = content
7174
return nil
7275
}
7376

@@ -76,19 +79,72 @@ func (c *StoredPastedContent) UnmarshalJSON(data []byte) error {
7679
if err := json.Unmarshal(data, &fields); err != nil {
7780
return err
7881
}
79-
*c = StoredPastedContent{
80-
ID: historyIntJSONField(fields, historyIDFieldNames()...),
81-
Type: canonicalPastedContentType(historyStringJSONField(fields, "type", "kind", "pastedType", "pasted_type")),
82-
Content: historyStringJSONField(fields, "content", "value", "data", "base64"),
83-
ContentHash: historyStringJSONField(fields, "contentHash", "content_hash", "hash", "contentDigest", "content_digest"),
84-
MediaType: historyStringJSONField(fields, "mediaType", "media_type", "mimeType", "mime_type", "contentType", "content_type"),
85-
Filename: historyStringJSONField(fields, "filename", "fileName", "file_name", "name"),
86-
Dimensions: historyDimensionsJSONField(fields, "dimensions", "imageDimensions", "image_dimensions"),
87-
SourcePath: historyStringJSONField(fields, "sourcePath", "source_path", "filePath", "file_path", "path"),
82+
if nested, ok := historyWrappedPayloadJSON(fields, historyPastedContentWrapperFieldNames(), historyPastedContentValueFieldNames(), nil); ok {
83+
var content StoredPastedContent
84+
if err := json.Unmarshal(nested, &content); err != nil {
85+
return err
86+
}
87+
historyApplyStoredPastedContentFields(&content, fields, false)
88+
*c = content
89+
return nil
8890
}
91+
var content StoredPastedContent
92+
historyApplyStoredPastedContentFields(&content, fields, true)
93+
*c = content
8994
return nil
9095
}
9196

97+
func historyApplyPastedContentFields(content *PastedContent, fields map[string]json.RawMessage, overwrite bool) {
98+
if value := historyIntJSONField(fields, historyIDFieldNames()...); value > 0 && (overwrite || content.ID == 0) {
99+
content.ID = value
100+
}
101+
if value := canonicalPastedContentType(historyStringJSONField(fields, "type", "kind", "pastedType", "pasted_type")); value != "" && (overwrite || content.Type == "") {
102+
content.Type = value
103+
}
104+
if value := historyStringJSONField(fields, "content", "value", "data", "base64"); value != "" && (overwrite || content.Content == "") {
105+
content.Content = value
106+
}
107+
if value := historyStringJSONField(fields, "mediaType", "media_type", "mimeType", "mime_type", "contentType", "content_type"); value != "" && (overwrite || content.MediaType == "") {
108+
content.MediaType = value
109+
}
110+
if value := historyStringJSONField(fields, "filename", "fileName", "file_name", "name"); value != "" && (overwrite || content.Filename == "") {
111+
content.Filename = value
112+
}
113+
if value := historyDimensionsJSONField(fields, "dimensions", "imageDimensions", "image_dimensions"); value != nil && (overwrite || content.Dimensions == nil) {
114+
content.Dimensions = value
115+
}
116+
if value := historyStringJSONField(fields, "sourcePath", "source_path", "filePath", "file_path", "path"); value != "" && (overwrite || content.SourcePath == "") {
117+
content.SourcePath = value
118+
}
119+
}
120+
121+
func historyApplyStoredPastedContentFields(content *StoredPastedContent, fields map[string]json.RawMessage, overwrite bool) {
122+
if value := historyIntJSONField(fields, historyIDFieldNames()...); value > 0 && (overwrite || content.ID == 0) {
123+
content.ID = value
124+
}
125+
if value := canonicalPastedContentType(historyStringJSONField(fields, "type", "kind", "pastedType", "pasted_type")); value != "" && (overwrite || content.Type == "") {
126+
content.Type = value
127+
}
128+
if value := historyStringJSONField(fields, "content", "value", "data", "base64"); value != "" && (overwrite || content.Content == "") {
129+
content.Content = value
130+
}
131+
if value := historyStringJSONField(fields, "contentHash", "content_hash", "hash", "contentDigest", "content_digest"); value != "" && (overwrite || content.ContentHash == "") {
132+
content.ContentHash = value
133+
}
134+
if value := historyStringJSONField(fields, "mediaType", "media_type", "mimeType", "mime_type", "contentType", "content_type"); value != "" && (overwrite || content.MediaType == "") {
135+
content.MediaType = value
136+
}
137+
if value := historyStringJSONField(fields, "filename", "fileName", "file_name", "name"); value != "" && (overwrite || content.Filename == "") {
138+
content.Filename = value
139+
}
140+
if value := historyDimensionsJSONField(fields, "dimensions", "imageDimensions", "image_dimensions"); value != nil && (overwrite || content.Dimensions == nil) {
141+
content.Dimensions = value
142+
}
143+
if value := historyStringJSONField(fields, "sourcePath", "source_path", "filePath", "file_path", "path"); value != "" && (overwrite || content.SourcePath == "") {
144+
content.SourcePath = value
145+
}
146+
}
147+
92148
func canonicalPastedContentType(value string) string {
93149
trimmed := strings.TrimSpace(value)
94150
normalized := strings.ToLower(trimmed)
@@ -338,10 +394,18 @@ func (e *HistoryEntry) UnmarshalJSON(data []byte) error {
338394
if err := json.Unmarshal(data, &fields); err != nil {
339395
return err
340396
}
341-
*e = HistoryEntry{
342-
Display: historyStringJSONField(fields, "display", "prompt", "text", "input", "content", "value"),
343-
PastedContents: historyPastedContentsJSONField(fields, historyPastedContentContainerFieldNames()...),
397+
if nested, ok := historyWrappedPayloadJSON(fields, historyEntryWrapperFieldNames(), historyEntryValueFieldNames(), historyPastedContentContainerFieldNames()); ok {
398+
var entry HistoryEntry
399+
if err := json.Unmarshal(nested, &entry); err != nil {
400+
return err
401+
}
402+
historyApplyEntryFields(&entry, fields, false)
403+
*e = entry
404+
return nil
344405
}
406+
var entry HistoryEntry
407+
historyApplyEntryFields(&entry, fields, true)
408+
*e = entry
345409
return nil
346410
}
347411

@@ -350,24 +414,120 @@ func (e *LogEntry) UnmarshalJSON(data []byte) error {
350414
if err := json.Unmarshal(data, &fields); err != nil {
351415
return err
352416
}
353-
*e = LogEntry{
354-
Display: historyStringJSONField(fields, "display", "prompt", "text", "input", "content", "value"),
355-
PastedContents: historyStoredPastedContentsJSONField(fields, historyPastedContentContainerFieldNames()...),
356-
Timestamp: historyTimestampJSONField(fields, "timestamp", "createdAt", "created_at", "time", "unixTimestamp", "unix_timestamp"),
357-
Project: historyStringJSONField(fields, "project", "projectPath", "project_path", "cwd", "cwdPath", "cwd_path", "workingDirectory", "working_directory", "workspacePath", "workspace_path", "workspace"),
358-
SessionID: firstHistoryID(
359-
historyIDJSONField(fields, "sessionId"),
360-
historyIDJSONField(fields, "sessionID"),
361-
historyIDJSONField(fields, "session_id"),
362-
historyIDJSONField(fields, "session"),
363-
historyIDJSONField(fields, "sessionUuid"),
364-
historyIDJSONField(fields, "sessionUUID"),
365-
historyIDJSONField(fields, "session_uuid"),
366-
),
417+
if nested, ok := historyWrappedPayloadJSON(fields, historyEntryWrapperFieldNames(), historyEntryValueFieldNames(), historyPastedContentContainerFieldNames()); ok {
418+
var entry LogEntry
419+
if err := json.Unmarshal(nested, &entry); err != nil {
420+
return err
421+
}
422+
historyApplyLogEntryFields(&entry, fields, false)
423+
*e = entry
424+
return nil
367425
}
426+
var entry LogEntry
427+
historyApplyLogEntryFields(&entry, fields, true)
428+
*e = entry
368429
return nil
369430
}
370431

432+
func historyApplyEntryFields(entry *HistoryEntry, fields map[string]json.RawMessage, overwrite bool) {
433+
if value := historyStringJSONField(fields, "display", "prompt", "text", "input", "content", "value"); value != "" && (overwrite || entry.Display == "") {
434+
entry.Display = value
435+
}
436+
if value := historyPastedContentsJSONField(fields, historyPastedContentContainerFieldNames()...); value != nil && (overwrite || len(entry.PastedContents) == 0) {
437+
entry.PastedContents = value
438+
}
439+
}
440+
441+
func historyApplyLogEntryFields(entry *LogEntry, fields map[string]json.RawMessage, overwrite bool) {
442+
if value := historyStringJSONField(fields, "display", "prompt", "text", "input", "content", "value"); value != "" && (overwrite || entry.Display == "") {
443+
entry.Display = value
444+
}
445+
if value := historyStoredPastedContentsJSONField(fields, historyPastedContentContainerFieldNames()...); value != nil && (overwrite || len(entry.PastedContents) == 0) {
446+
entry.PastedContents = value
447+
}
448+
if value := historyTimestampJSONField(fields, "timestamp", "createdAt", "created_at", "time", "unixTimestamp", "unix_timestamp"); value != 0 && (overwrite || entry.Timestamp == 0) {
449+
entry.Timestamp = value
450+
}
451+
if value := historyStringJSONField(fields, "project", "projectPath", "project_path", "cwd", "cwdPath", "cwd_path", "workingDirectory", "working_directory", "workspacePath", "workspace_path", "workspace"); value != "" && (overwrite || entry.Project == "") {
452+
entry.Project = value
453+
}
454+
if value := firstHistoryID(
455+
historyIDJSONField(fields, "sessionId"),
456+
historyIDJSONField(fields, "sessionID"),
457+
historyIDJSONField(fields, "session_id"),
458+
historyIDJSONField(fields, "session"),
459+
historyIDJSONField(fields, "sessionUuid"),
460+
historyIDJSONField(fields, "sessionUUID"),
461+
historyIDJSONField(fields, "session_uuid"),
462+
); value != "" && (overwrite || entry.SessionID == "") {
463+
entry.SessionID = value
464+
}
465+
}
466+
467+
func historyEntryWrapperFieldNames() []string {
468+
return []string{"entry", "record", "item", "payload", "body", "data", "result", "value", "historyEntry", "history_entry", "logEntry", "log_entry"}
469+
}
470+
471+
func historyEntryValueFieldNames() []string {
472+
return []string{"display", "prompt", "text", "input", "content", "value"}
473+
}
474+
475+
func historyPastedContentWrapperFieldNames() []string {
476+
return []string{"pastedContent", "pasted_content", "storedPastedContent", "stored_pasted_content", "attachment", "paste", "entry", "record", "item", "payload", "body", "data", "result", "value"}
477+
}
478+
479+
func historyPastedContentValueFieldNames() []string {
480+
return []string{"content", "value", "data", "base64"}
481+
}
482+
483+
func historyWrappedPayloadJSON(fields map[string]json.RawMessage, wrappers []string, scalarDirect []string, containerDirect []string) (json.RawMessage, bool) {
484+
if historyHasScalarPayload(fields, scalarDirect) || historyHasContainerPayload(fields, containerDirect) {
485+
return nil, false
486+
}
487+
for _, name := range wrappers {
488+
raw, ok := fields[name]
489+
if !ok {
490+
continue
491+
}
492+
trimmed := bytes.TrimSpace(raw)
493+
if len(trimmed) > 0 && trimmed[0] == '{' {
494+
return raw, true
495+
}
496+
}
497+
return nil, false
498+
}
499+
500+
func historyHasScalarPayload(fields map[string]json.RawMessage, names []string) bool {
501+
for _, name := range names {
502+
raw, ok := fields[name]
503+
if !ok {
504+
continue
505+
}
506+
trimmed := bytes.TrimSpace(raw)
507+
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
508+
continue
509+
}
510+
if trimmed[0] != '{' {
511+
return true
512+
}
513+
}
514+
return false
515+
}
516+
517+
func historyHasContainerPayload(fields map[string]json.RawMessage, names []string) bool {
518+
for _, name := range names {
519+
raw, ok := fields[name]
520+
if !ok {
521+
continue
522+
}
523+
trimmed := bytes.TrimSpace(raw)
524+
if len(trimmed) > 0 && !bytes.Equal(trimmed, []byte("null")) {
525+
return true
526+
}
527+
}
528+
return false
529+
}
530+
371531
func firstHistoryID(values ...contracts.ID) contracts.ID {
372532
for _, value := range values {
373533
if value != "" {

internal/session/history_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,31 @@ func TestHistoryEntryAcceptsPastedContentContainerAliases(t *testing.T) {
265265
}
266266
}
267267

268+
func TestHistoryEntryAcceptsWrapperObjects(t *testing.T) {
269+
var entry HistoryEntry
270+
data := `{
271+
"historyEntry": {
272+
"prompt": "restore [Pasted text #12] [Image #13]",
273+
"attachments": [
274+
{"record": {"attachmentID": "12", "kind": "input_text", "value": "wrapped memo"}},
275+
{"payload": {"imageID": "13", "type": "pasted-image", "data": "CCCC", "mimeType": "image/png", "name": "wrapped.png"}}
276+
]
277+
}
278+
}`
279+
if err := json.Unmarshal([]byte(data), &entry); err != nil {
280+
t.Fatal(err)
281+
}
282+
if entry.Display != "restore [Pasted text #12] [Image #13]" {
283+
t.Fatalf("wrapped display = %#v", entry)
284+
}
285+
if got := entry.PastedContents[12]; got.ID != 12 || got.Type != PastedContentText || got.Content != "wrapped memo" {
286+
t.Fatalf("wrapped text = %#v", got)
287+
}
288+
if got := entry.PastedContents[13]; got.ID != 13 || got.Type != PastedContentImage || got.Content != "CCCC" || got.MediaType != "image/png" || got.Filename != "wrapped.png" {
289+
t.Fatalf("wrapped image = %#v", got)
290+
}
291+
}
292+
268293
func TestImageDimensionsWidthHeightDefaultDisplaySize(t *testing.T) {
269294
var dimensions ImageDimensions
270295
if err := json.Unmarshal([]byte(`{"width":4000,"height":2000}`), &dimensions); err != nil {
@@ -342,6 +367,33 @@ func TestLoadHistoryAcceptsStoredPastedContentContainerAliases(t *testing.T) {
342367
}
343368
}
344369

370+
func TestLoadHistoryAcceptsWrappedEntriesAndPastedContentItems(t *testing.T) {
371+
path := filepath.Join(t.TempDir(), "history.jsonl")
372+
line := `{"payload":{"input":"restore [Pasted text #14] [Image #15]","attachments":[{"entry":{"contentID":"14","pasted_type":"input_text","contentDigest":"wrapped_hash","contentType":"text/plain"}},{"item":{"imageID":"15","kind":"input_image","hash":"image_hash","mimeType":"image/png","fileName":"wrapped-history.png"}}]},"createdAt":"1970-01-01T00:00:01Z","workspacePath":"/repo","session":"session"}`
373+
if err := os.WriteFile(path, []byte(line+"\n"), 0o600); err != nil {
374+
t.Fatal(err)
375+
}
376+
377+
history, err := LoadHistory(path, "/repo", "session", MaxHistoryItems, func(hash string) (string, bool) {
378+
if hash == "wrapped_hash" {
379+
return "expanded wrapped memo", true
380+
}
381+
return "", false
382+
})
383+
if err != nil {
384+
t.Fatal(err)
385+
}
386+
if len(history) != 1 || history[0].Display != "restore [Pasted text #14] [Image #15]" {
387+
t.Fatalf("history = %#v", history)
388+
}
389+
if got := history[0].PastedContents[14]; got.ID != 14 || got.Type != PastedContentText || got.Content != "expanded wrapped memo" || got.MediaType != "text/plain" {
390+
t.Fatalf("wrapped stored text = %#v", got)
391+
}
392+
if got := history[0].PastedContents[15]; got.ID != 15 || got.Type != PastedContentImage || got.MediaType != "image/png" || got.Filename != "wrapped-history.png" {
393+
t.Fatalf("wrapped stored image = %#v", got)
394+
}
395+
}
396+
345397
func TestLoadTimestampedHistoryDedupesNewestFirst(t *testing.T) {
346398
path := filepath.Join(t.TempDir(), "history.jsonl")
347399
project := "/repo"

0 commit comments

Comments
 (0)