diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index 4e4876d5..7127b3e1 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -1121,13 +1121,13 @@ func TestRuntimeMemoExtractorFuncSchedule(t *testing.T) { if sessionID != "session-1" { t.Fatalf("unexpected session id %q", sessionID) } - if len(messages) != 1 || messages[0].Content != "hi" { + if len(messages) != 1 || renderPartsForTest(messages[0].Parts) != "hi" { t.Fatalf("unexpected messages %+v", messages) } }) extractor.Schedule("session-1", []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "hi"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}, }) if !called { @@ -1144,14 +1144,14 @@ func TestTextGenAdapterGenerate(t *testing.T) { if prompt != "prompt" { t.Fatalf("unexpected prompt %q", prompt) } - if len(msgs) != 1 || msgs[0].Content != "msg" { + if len(msgs) != 1 || renderPartsForTest(msgs[0].Parts) != "msg" { t.Fatalf("unexpected messages %+v", msgs) } return "ok", nil }) result, err := adapter.Generate(context.Background(), "prompt", []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "msg"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("msg")}}, }) if err != nil { t.Fatalf("Generate() error = %v", err) @@ -1173,7 +1173,7 @@ func TestNewMemoExtractorAdapterSkipsWhenSchedulerNil(t *testing.T) { extractor := newMemoExtractorAdapter(nil, manager, nil) extractor.Schedule("session-1", []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "remember this"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("remember this")}}, }) } @@ -1187,7 +1187,7 @@ func TestNewMemoExtractorAdapterSkipsWhenResolveProviderFails(t *testing.T) { extractor := newMemoExtractorAdapter(nil, manager, scheduler) extractor.Schedule("session-1", []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "remember this"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("remember this")}}, }) if scheduler.called { @@ -1218,7 +1218,7 @@ func TestNewMemoExtractorAdapterSchedulesExtractorAndGenerates(t *testing.T) { extractor := newMemoExtractorAdapter(factory, manager, scheduler) inputMessages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "remember 我偏好 Go"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("remember 我偏好 Go")}}, } extractor.Schedule("session-1", inputMessages) @@ -1266,14 +1266,14 @@ func TestNewMemoExtractorAdapterBuildsProviderSafeMemoWindow(t *testing.T) { extractor := newMemoExtractorAdapter(factory, manager, scheduler) inputMessages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "first"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("first")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call_1", Name: "filesystem_read_file", Arguments: `{}`}, }, }, - {Role: providertypes.RoleUser, Content: "second"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("second")}}, } extractor.Schedule("session-1", inputMessages) if !scheduler.called || scheduler.extractor == nil { @@ -1319,7 +1319,7 @@ func TestNewMemoExtractorAdapterKeepsScheduledConfigSnapshot(t *testing.T) { scheduler := &stubMemoExtractorScheduler{} extractor := newMemoExtractorAdapter(factory, manager, scheduler) - messages := []providertypes.Message{{Role: providertypes.RoleUser, Content: "remember snapshot"}} + messages := []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("remember snapshot")}}} extractor.Schedule("session-1", messages) if !scheduler.called || scheduler.extractor == nil { t.Fatalf("expected scheduler to receive extractor") @@ -1358,7 +1358,7 @@ func TestNewMemoExtractorAdapterPropagatesFactoryBuildError(t *testing.T) { extractor := newMemoExtractorAdapter(factory, manager, scheduler) inputMessages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "remember coding style"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("remember coding style")}}, } extractor.Schedule("session-1", inputMessages) if !scheduler.called || scheduler.extractor == nil { diff --git a/internal/app/parts_render_test.go b/internal/app/parts_render_test.go new file mode 100644 index 00000000..659298df --- /dev/null +++ b/internal/app/parts_render_test.go @@ -0,0 +1,20 @@ +package app + +import ( + "strings" + + providertypes "neo-code/internal/provider/types" +) + +func renderPartsForTest(parts []providertypes.ContentPart) string { + var builder strings.Builder + for _, part := range parts { + switch part.Kind { + case providertypes.ContentPartText: + builder.WriteString(part.Text) + case providertypes.ContentPartImage: + builder.WriteString("[Image]") + } + } + return builder.String() +} diff --git a/internal/config/provider.go b/internal/config/provider.go index 4c0d036c..1487e1dd 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -198,7 +198,7 @@ const ( QiniuName = "qiniu" QiniuDefaultBaseURL = "https://api.qnaigc.com/v1" - QiniuDefaultModel = "openai/gpt-5" + QiniuDefaultModel = "z-ai/glm-5.1" QiniuDefaultAPIKeyEnv = "QINIU_API_KEY" ) diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 966eed5a..8b7b7e05 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -37,7 +37,7 @@ func TestDefaultBuilderBuild(t *testing.T) { builder := NewBuilder() input := BuildInput{ Messages: []providertypes.Message{ - {Role: "user", Content: "hello"}, + {Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, }, Metadata: testMetadata(t.TempDir()), } @@ -95,7 +95,7 @@ func TestDefaultBuilderBuildComposesPromptSectionsInOrder(t *testing.T) { builder := NewBuilder() got, err := builder.Build(stdcontext.Background(), BuildInput{ - Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Metadata: testMetadata(root), }) if err != nil { @@ -118,7 +118,7 @@ func TestDefaultBuilderBuildIncludesTaskStateBeforeSystemState(t *testing.T) { builder := NewBuilder() got, err := builder.Build(stdcontext.Background(), BuildInput{ - Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, TaskState: agentsession.TaskState{ Goal: "Finish task state refactor", OpenItems: []string{"Update tests"}, @@ -148,7 +148,7 @@ func TestDefaultBuilderBuildIncludesTodosBeforeSystemState(t *testing.T) { builder := NewBuilder() got, err := builder.Build(stdcontext.Background(), BuildInput{ - Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Todos: []agentsession.TodoItem{ { ID: "todo-1", @@ -181,8 +181,8 @@ func TestDefaultBuilderBuildUsesSpanTrimPolicyWhenTrimPolicyIsUnset(t *testing.T messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+2) for i := 0; i < maxRetainedMessageSpans+2; i++ { messages = append(messages, providertypes.Message{ - Role: providertypes.RoleUser, - Content: fmt.Sprintf("u-%d", i), + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(fmt.Sprintf("u-%d", i))}, }) } @@ -202,7 +202,7 @@ func TestDefaultBuilderBuildUsesSpanTrimPolicyWhenTrimPolicyIsUnset(t *testing.T if len(got.Messages) != maxRetainedMessageSpans { t.Fatalf("expected %d retained messages, got %d", maxRetainedMessageSpans, len(got.Messages)) } - if got.Messages[0].Content != "u-2" { + if renderDisplayParts(got.Messages[0].Parts) != "u-2" { t.Fatalf("expected oldest messages to be trimmed, got first message %+v", got.Messages[0]) } } @@ -232,30 +232,30 @@ func TestDefaultBuilderBuildAppliesMicroCompactAfterTrim(t *testing.T) { } messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old read result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old read result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "current reply"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current reply")}}, } got, err := builder.Build(stdcontext.Background(), BuildInput{ @@ -268,14 +268,14 @@ func TestDefaultBuilderBuildAppliesMicroCompactAfterTrim(t *testing.T) { if len(got.Messages) != len(messages) { t.Fatalf("expected builder output to keep message count, got %d want %d", len(got.Messages), len(messages)) } - if got.Messages[2].Content != microCompactClearedMessage { - t.Fatalf("expected builder output to clear older tool result, got %q", got.Messages[2].Content) + if renderDisplayParts(got.Messages[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected builder output to clear older tool result, got %q", renderDisplayParts(got.Messages[2].Parts)) } - if got.Messages[4].Content != "recent bash result" { - t.Fatalf("expected recent tool result to stay visible, got %q", got.Messages[4].Content) + if renderDisplayParts(got.Messages[4].Parts) != "recent bash result" { + t.Fatalf("expected recent tool result to stay visible, got %q", renderDisplayParts(got.Messages[4].Parts)) } - if got.Messages[6].Content != "latest webfetch result" { - t.Fatalf("expected latest tool result to stay visible, got %q", got.Messages[6].Content) + if renderDisplayParts(got.Messages[6].Parts) != "latest webfetch result" { + t.Fatalf("expected latest tool result to stay visible, got %q", renderDisplayParts(got.Messages[6].Parts)) } } @@ -289,29 +289,29 @@ func TestDefaultBuilderBuildSkipsMicroCompactWithoutEstablishedTaskState(t *test } messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old read result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old read result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, } got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages}) if err != nil { t.Fatalf("Build() error = %v", err) } - if got.Messages[2].Content != "old read result" { - t.Fatalf("expected old tool result to remain visible without task state, got %q", got.Messages[2].Content) + if renderDisplayParts(got.Messages[2].Parts) != "old read result" { + t.Fatalf("expected old tool result to remain visible without task state, got %q", renderDisplayParts(got.Messages[2].Parts)) } } @@ -325,30 +325,30 @@ func TestDefaultBuilderBuildSkipsMicroCompactWhenDisabled(t *testing.T) { } messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old read result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old read result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "current reply"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current reply")}}, } got, err := builder.Build(stdcontext.Background(), BuildInput{ @@ -381,37 +381,37 @@ func TestDefaultBuilderBuildHonorsToolMicroCompactPolicies(t *testing.T) { } messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "custom_tool", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old custom result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old custom result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, } got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages}) if err != nil { t.Fatalf("Build() error = %v", err) } - if got.Messages[2].Content != "old custom result" { - t.Fatalf("expected preserved tool result to remain, got %q", got.Messages[2].Content) + if renderDisplayParts(got.Messages[2].Parts) != "old custom result" { + t.Fatalf("expected preserved tool result to remain, got %q", renderDisplayParts(got.Messages[2].Parts)) } } @@ -423,37 +423,37 @@ func TestNewBuilderWithToolPoliciesUsesProvidedPolicySource(t *testing.T) { }) messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "custom_tool", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old custom result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old custom result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, } got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages}) if err != nil { t.Fatalf("Build() error = %v", err) } - if got.Messages[2].Content != "old custom result" { - t.Fatalf("expected preserved tool result to remain, got %q", got.Messages[2].Content) + if renderDisplayParts(got.Messages[2].Parts) != "old custom result" { + t.Fatalf("expected preserved tool result to remain, got %q", renderDisplayParts(got.Messages[2].Parts)) } } @@ -462,7 +462,7 @@ func TestTrimMessagesPreservesToolPairs(t *testing.T) { messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+4) for i := 0; i < 8; i++ { - messages = append(messages, providertypes.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) + messages = append(messages, providertypes.Message{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart(fmt.Sprintf("u-%d", i))}}) } messages = append(messages, providertypes.Message{ @@ -471,9 +471,9 @@ func TestTrimMessagesPreservesToolPairs(t *testing.T) { {ID: "call-1", Name: "filesystem_edit", Arguments: "{}"}, }, }, - providertypes.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-result"}, - providertypes.Message{Role: "assistant", Content: "after-tool"}, - providertypes.Message{Role: "user", Content: "latest"}, + providertypes.Message{Role: "tool", ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool-result")}}, + providertypes.Message{Role: "assistant", Parts: []providertypes.ContentPart{providertypes.NewTextPart("after-tool")}}, + providertypes.Message{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest")}}, ) trimmed := trimMessages(messages, maxRetainedMessageSpans) @@ -503,25 +503,25 @@ func TestTrimMessagesProtectsLatestExplicitUserInstructionTail(t *testing.T) { messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+5) for i := 0; i < 2; i++ { - messages = append(messages, providertypes.Message{Role: providertypes.RoleUser, Content: fmt.Sprintf("old-%d", i)}) + messages = append(messages, providertypes.Message{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart(fmt.Sprintf("old-%d", i))}}) } messages = append(messages, - providertypes.Message{Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-1"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-2"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-3"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-4"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-5"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-6"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-7"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-8"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-9"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-10"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-11"}, + providertypes.Message{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow-up-1")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow-up-2")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow-up-3")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow-up-4")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow-up-5")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow-up-6")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow-up-7")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow-up-8")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow-up-9")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow-up-10")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow-up-11")}}, ) trimmed := trimMessages(messages, retainedSpans) - if trimmed[0].Role != providertypes.RoleUser || trimmed[0].Content != "latest explicit instruction" { + if trimmed[0].Role != providertypes.RoleUser || renderDisplayParts(trimmed[0].Parts) != "latest explicit instruction" { t.Fatalf("expected protected tail to keep latest explicit user instruction, got %+v", trimmed[0]) } if len(trimmed) != 12 { @@ -536,7 +536,7 @@ func TestTrimMessagesUsesSharedSpanModel(t *testing.T) { messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+6) for i := 0; i < 3; i++ { - messages = append(messages, providertypes.Message{Role: providertypes.RoleUser, Content: fmt.Sprintf("u-%d", i)}) + messages = append(messages, providertypes.Message{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart(fmt.Sprintf("u-%d", i))}}) } messages = append(messages, providertypes.Message{ @@ -545,23 +545,23 @@ func TestTrimMessagesUsesSharedSpanModel(t *testing.T) { {ID: "call-2", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - providertypes.Message{Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "tool-result"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "after tool"}, - providertypes.Message{Role: providertypes.RoleUser, Content: "u-4"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "a-5"}, - providertypes.Message{Role: providertypes.RoleUser, Content: "u-6"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "a-7"}, - providertypes.Message{Role: providertypes.RoleUser, Content: "u-8"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "a-9"}, - providertypes.Message{Role: providertypes.RoleUser, Content: "u-10"}, - providertypes.Message{Role: providertypes.RoleAssistant, Content: "a-11"}, + providertypes.Message{Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool-result")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("after tool")}}, + providertypes.Message{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("u-4")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("a-5")}}, + providertypes.Message{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("u-6")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("a-7")}}, + providertypes.Message{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("u-8")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("a-9")}}, + providertypes.Message{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("u-10")}}, + providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("a-11")}}, ) spans := internalcompact.BuildMessageSpans(messages) trimmed := trimMessages(messages, retainedSpans) start := spans[len(spans)-retainedSpans].Start - if len(trimmed) == 0 || trimmed[0].Content != messages[start].Content { + if len(trimmed) == 0 || renderDisplayParts(trimmed[0].Parts) != renderDisplayParts(messages[start].Parts) { t.Fatalf("expected trim to start from shared span boundary %d, got %+v", start, trimmed) } if trimmed[0].Role != providertypes.RoleAssistant || len(trimmed[0].ToolCalls) != 1 { @@ -581,8 +581,8 @@ func TestTrimMessagesBoundaries(t *testing.T) { { name: "within max turns returns full cloned slice", input: []providertypes.Message{ - {Role: "user", Content: "one"}, - {Role: "assistant", Content: "two"}, + {Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("one")}}, + {Role: "assistant", Parts: []providertypes.ContentPart{providertypes.NewTextPart("two")}}, }, wantLen: 2, assert: func(t *testing.T, original []providertypes.Message, trimmed []providertypes.Message) { @@ -597,7 +597,7 @@ func TestTrimMessagesBoundaries(t *testing.T) { input: func() []providertypes.Message { messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+3) for i := 0; i < maxRetainedMessageSpans-1; i++ { - messages = append(messages, providertypes.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) + messages = append(messages, providertypes.Message{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart(fmt.Sprintf("u-%d", i))}}) } messages = append(messages, providertypes.Message{ @@ -606,8 +606,8 @@ func TestTrimMessagesBoundaries(t *testing.T) { {ID: "call-1", Name: "filesystem_edit", Arguments: "{}"}, }, }, - providertypes.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-1"}, - providertypes.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-2"}, + providertypes.Message{Role: "tool", ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool-1")}}, + providertypes.Message{Role: "tool", ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool-2")}}, ) return messages }(), @@ -624,7 +624,7 @@ func TestTrimMessagesBoundaries(t *testing.T) { input: func() []providertypes.Message { messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+5) for i := 0; i < maxRetainedMessageSpans+1; i++ { - messages = append(messages, providertypes.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) + messages = append(messages, providertypes.Message{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart(fmt.Sprintf("u-%d", i))}}) } messages = append(messages, providertypes.Message{ @@ -633,14 +633,14 @@ func TestTrimMessagesBoundaries(t *testing.T) { {ID: "call-2", Name: "filesystem_edit", Arguments: "{}"}, }, }, - providertypes.Message{Role: "tool", ToolCallID: "call-2", Content: "tool-result"}, + providertypes.Message{Role: "tool", ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool-result")}}, ) return messages }(), wantLen: maxRetainedMessageSpans + 1, assert: func(t *testing.T, original []providertypes.Message, trimmed []providertypes.Message) { t.Helper() - if trimmed[0].Content != "u-2" { + if renderDisplayParts(trimmed[0].Parts) != "u-2" { t.Fatalf("expected oldest spans to be removed, got first message %+v", trimmed[0]) } if trimmed[len(trimmed)-1].Role != "tool" { @@ -669,7 +669,7 @@ func TestBuildAutoCompactSuggestedDisabled(t *testing.T) { builder := NewBuilder() input := BuildInput{ - Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Metadata: testMetadata(t.TempDir()), Compact: CompactOptions{AutoCompactThreshold: 0}, } @@ -689,7 +689,7 @@ func TestBuildAutoCompactSuggestedBelowThreshold(t *testing.T) { builder := NewBuilder() input := BuildInput{ - Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Metadata: testMetadata(t.TempDir()), Compact: CompactOptions{AutoCompactThreshold: 100}, } @@ -709,7 +709,7 @@ func TestBuildAutoCompactSuggestedAtThreshold(t *testing.T) { builder := NewBuilder() input := BuildInput{ - Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Metadata: testMetadata(t.TempDir()), Compact: CompactOptions{AutoCompactThreshold: 100}, } @@ -729,7 +729,7 @@ func TestBuildAutoCompactSuggestedAboveThreshold(t *testing.T) { builder := NewBuilder() input := BuildInput{ - Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Metadata: testMetadata(t.TempDir()), Compact: CompactOptions{AutoCompactThreshold: 100}, } @@ -753,7 +753,7 @@ func TestNewBuilderWithMemo(t *testing.T) { } builder := NewBuilderWithMemo(stubMicroCompactPolicySource{}, memoSource) input := BuildInput{ - Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Metadata: testMetadata(t.TempDir()), } result, err := builder.Build(stdcontext.Background(), input) @@ -771,7 +771,7 @@ func TestNewBuilderWithMemo(t *testing.T) { t.Run("nil memo source skips memo section", func(t *testing.T) { builder := NewBuilderWithMemo(stubMicroCompactPolicySource{}, nil) input := BuildInput{ - Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Metadata: testMetadata(t.TempDir()), } result, err := builder.Build(stdcontext.Background(), input) @@ -791,7 +791,7 @@ func TestProjectToolMessagesForModelKeepsBuilderProjectionBehavior(t *testing.T) { Role: providertypes.RoleTool, ToolCallID: "call-1", - Content: "tool output", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool output")}, ToolMetadata: map[string]string{"tool_name": "filesystem_read_file", "path": "README.md"}, }, } @@ -800,8 +800,9 @@ func TestProjectToolMessagesForModelKeepsBuilderProjectionBehavior(t *testing.T) if len(projected) != 1 { t.Fatalf("len(projected) = %d, want 1", len(projected)) } - if !strings.Contains(projected[0].Content, "tool result") || !strings.Contains(projected[0].Content, "tool: filesystem_read_file") { - t.Fatalf("unexpected projected content: %q", projected[0].Content) + projectedText := renderDisplayParts(projected[0].Parts) + if !strings.Contains(projectedText, "tool result") || !strings.Contains(projectedText, "tool: filesystem_read_file") { + t.Fatalf("unexpected projected content: %q", projectedText) } if projected[0].ToolMetadata != nil { t.Fatalf("expected projected metadata to be cleared, got %#v", projected[0].ToolMetadata) @@ -817,7 +818,7 @@ func TestDefaultBuilderBuildProjectsMetadataOnlyToolResult(t *testing.T) { builder := NewBuilder() result, err := builder.Build(stdcontext.Background(), BuildInput{ Messages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "inspect README"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("inspect README")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ @@ -827,7 +828,7 @@ func TestDefaultBuilderBuildProjectsMetadataOnlyToolResult(t *testing.T) { { Role: providertypes.RoleTool, ToolCallID: "call-1", - Content: " ", + Parts: []providertypes.ContentPart{providertypes.NewTextPart(" ")}, ToolMetadata: map[string]string{"tool_name": "filesystem_read_file", "path": "README.md"}, }, }, @@ -843,10 +844,11 @@ func TestDefaultBuilderBuildProjectsMetadataOnlyToolResult(t *testing.T) { if toolMessage.Role != providertypes.RoleTool { t.Fatalf("expected tool message at index 2, got %+v", toolMessage) } - if !strings.Contains(toolMessage.Content, "tool result") || - !strings.Contains(toolMessage.Content, "tool: filesystem_read_file") || - !strings.Contains(toolMessage.Content, "meta.path: README.md") { - t.Fatalf("expected metadata-only tool result to be projected, got %q", toolMessage.Content) + toolMessageText := renderDisplayParts(toolMessage.Parts) + if !strings.Contains(toolMessageText, "tool result") || + !strings.Contains(toolMessageText, "tool: filesystem_read_file") || + !strings.Contains(toolMessageText, "meta.path: README.md") { + t.Fatalf("expected metadata-only tool result to be projected, got %q", toolMessageText) } if toolMessage.ToolMetadata != nil { t.Fatalf("expected projected tool metadata to be cleared, got %#v", toolMessage.ToolMetadata) diff --git a/internal/context/compact/helpers.go b/internal/context/compact/helpers.go index 12e6d506..7d7ad3d8 100644 --- a/internal/context/compact/helpers.go +++ b/internal/context/compact/helpers.go @@ -31,7 +31,7 @@ func countMessageChars(messages []providertypes.Message) int { total := 0 for _, message := range messages { total += utf8.RuneCountInString(message.Role) - total += utf8.RuneCountInString(message.Content) + total += utf8.RuneCountInString(renderTranscriptParts(message.Parts)) total += utf8.RuneCountInString(message.ToolCallID) for _, call := range message.ToolCalls { total += utf8.RuneCountInString(call.ID) diff --git a/internal/context/compact/parts_render.go b/internal/context/compact/parts_render.go new file mode 100644 index 00000000..0d91a5d7 --- /dev/null +++ b/internal/context/compact/parts_render.go @@ -0,0 +1,11 @@ +package compact + +import ( + "neo-code/internal/partsrender" + providertypes "neo-code/internal/provider/types" +) + +// renderTranscriptParts 将消息 Parts 渲染为 transcript 可读文本,避免泄露二进制内容。 +func renderTranscriptParts(parts []providertypes.ContentPart) string { + return partsrender.RenderTranscriptParts(parts) +} diff --git a/internal/context/compact/planner_test.go b/internal/context/compact/planner_test.go index bcce24fd..dc5cf0c1 100644 --- a/internal/context/compact/planner_test.go +++ b/internal/context/compact/planner_test.go @@ -12,12 +12,12 @@ func TestCompactionPlannerKeepRecentPlan(t *testing.T) { planner := compactionPlanner{} plan, err := planner.Plan(ModeManual, []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "old request"}, - {Role: providertypes.RoleAssistant, Content: "old answer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old request")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old answer")}}, {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}}}, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "tool result"}, - {Role: providertypes.RoleUser, Content: "latest instruction"}, - {Role: providertypes.RoleAssistant, Content: "latest answer"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest answer")}}, }, config.CompactConfig{ ManualStrategy: config.CompactManualStrategyKeepRecent, ManualKeepRecentMessages: 3, @@ -44,10 +44,10 @@ func TestCompactionPlannerFullReplaceProtectsLatestExplicitUserInstruction(t *te planner := compactionPlanner{} plan, err := planner.Plan(ModeManual, []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "old request"}, - {Role: providertypes.RoleAssistant, Content: "old answer"}, - {Role: providertypes.RoleUser, Content: "latest instruction"}, - {Role: providertypes.RoleAssistant, Content: "latest answer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old request")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest answer")}}, }, config.CompactConfig{ ManualStrategy: config.CompactManualStrategyFullReplace, }) @@ -60,7 +60,7 @@ func TestCompactionPlannerFullReplaceProtectsLatestExplicitUserInstruction(t *te if len(plan.Archived) != 2 || len(plan.Retained) != 2 { t.Fatalf("unexpected full_replace plan: %+v", plan) } - if plan.Retained[0].Role != providertypes.RoleUser || plan.Retained[0].Content != "latest instruction" { + if plan.Retained[0].Role != providertypes.RoleUser || renderTranscriptParts(plan.Retained[0].Parts) != "latest instruction" { t.Fatalf("expected latest explicit user instruction to stay retained, got %+v", plan.Retained) } } @@ -78,10 +78,10 @@ func TestCompactionPlannerReactiveModeAlwaysUsesKeepRecentStrategy(t *testing.T) t.Parallel() plan, err := (compactionPlanner{}).Plan(ModeReactive, []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "old request"}, - {Role: providertypes.RoleAssistant, Content: "old answer"}, - {Role: providertypes.RoleUser, Content: "latest request"}, - {Role: providertypes.RoleAssistant, Content: "latest answer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old request")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest request")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest answer")}}, }, config.CompactConfig{ ManualStrategy: "unsupported", ManualKeepRecentMessages: 2, diff --git a/internal/context/compact/runner.go b/internal/context/compact/runner.go index b093a266..bfa6aafd 100644 --- a/internal/context/compact/runner.go +++ b/internal/context/compact/runner.go @@ -180,7 +180,7 @@ func (s *Service) Run(ctx context.Context, input Input) (Result, error) { output.TaskState.LastUpdatedAt = s.now() next := make([]providertypes.Message, 0, len(plan.Retained)+1) - next = append(next, providertypes.Message{Role: providertypes.RoleAssistant, Content: output.DisplaySummary}) + next = append(next, providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart(output.DisplaySummary)}}) next = append(next, plan.Retained...) afterChars := countMessageChars(next) @@ -285,7 +285,7 @@ func isCompactSummaryMessage(message providertypes.Message) bool { if message.Role != providertypes.RoleAssistant { return false } - return strings.HasPrefix(strings.TrimSpace(message.Content), "[compact_summary]") + return strings.HasPrefix(strings.TrimSpace(renderTranscriptParts(message.Parts)), "[compact_summary]") } // validateGeneratedTaskState 确保 compact 生成结果真正建立了 durable task state,避免“摘要成功但状态为空”。 diff --git a/internal/context/compact/runner_test.go b/internal/context/compact/runner_test.go index 9294e59f..8e56764d 100644 --- a/internal/context/compact/runner_test.go +++ b/internal/context/compact/runner_test.go @@ -74,18 +74,18 @@ func TestManualCompactKeepRecentRetainsRecentMessagesAndWholeToolBlock(t *testin runner.userHomeDir = func() (string, error) { return home, nil } messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "old requirement"}, - {Role: providertypes.RoleAssistant, Content: "old answer"}, - {Role: providertypes.RoleUser, Content: "middle request"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old requirement")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("middle request")}}, {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "call-old", Name: "filesystem_grep", Arguments: "{}"}}}, - {Role: providertypes.RoleTool, ToolCallID: "call-old", Content: "old result"}, - {Role: providertypes.RoleAssistant, Content: "after tool"}, - {Role: providertypes.RoleUser, Content: "instruction to keep"}, - {Role: providertypes.RoleAssistant, Content: "ack"}, - {Role: providertypes.RoleUser, Content: "recent follow up"}, - {Role: providertypes.RoleAssistant, Content: "recent answer"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "latest result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-old", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old result")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("after tool")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("instruction to keep")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("ack")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent follow up")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest result")}}, } result, err := runner.Run(context.Background(), Input{ @@ -112,8 +112,9 @@ func TestManualCompactKeepRecentRetainsRecentMessagesAndWholeToolBlock(t *testin t.Fatalf("expected summary role assistant, got %q", result.Messages[0].Role) } for _, section := range []string{"done:", "in_progress:", "decisions:", "code_changes:", "constraints:"} { - if !strings.Contains(result.Messages[0].Content, section) { - t.Fatalf("expected summary to include section %q, got %q", section, result.Messages[0].Content) + summaryText := renderTranscriptParts(result.Messages[0].Parts) + if !strings.Contains(summaryText, section) { + t.Fatalf("expected summary to include section %q, got %q", section, summaryText) } } if result.Messages[1].Role != providertypes.RoleAssistant || len(result.Messages[1].ToolCalls) != 1 { @@ -149,11 +150,11 @@ func TestManualCompactPassesCurrentTaskStateAndFiltersOldDisplaySummary(t *testi NextStep: "Patch compact runner tests", } messages := []providertypes.Message{ - {Role: providertypes.RoleAssistant, Content: validSemanticSummary()}, - {Role: providertypes.RoleUser, Content: "older request"}, - {Role: providertypes.RoleAssistant, Content: "older answer"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "latest answer"}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart(validSemanticSummary())}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older request")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest answer")}}, } result, err := runner.Run(context.Background(), Input{ @@ -183,7 +184,7 @@ func TestManualCompactPassesCurrentTaskStateAndFiltersOldDisplaySummary(t *testi if len(generator.calls[0].ArchivedMessages) != 2 { t.Fatalf("expected old display summary filtered from archived messages, got %+v", generator.calls[0].ArchivedMessages) } - if strings.HasPrefix(strings.TrimSpace(generator.calls[0].ArchivedMessages[0].Content), "[compact_summary]") { + if strings.HasPrefix(strings.TrimSpace(renderTranscriptParts(generator.calls[0].ArchivedMessages[0].Parts)), "[compact_summary]") { t.Fatalf("expected compact summary message to be filtered, got %+v", generator.calls[0].ArchivedMessages) } } @@ -197,18 +198,18 @@ func TestReactiveCompactUsesKeepRecentAndReportsReactiveMode(t *testing.T) { runner.userHomeDir = func() (string, error) { return home, nil } messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "old requirement"}, - {Role: providertypes.RoleAssistant, Content: "old answer"}, - {Role: providertypes.RoleUser, Content: "middle request"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old requirement")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("middle request")}}, {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "call-old", Name: "filesystem_grep", Arguments: "{}"}}}, - {Role: providertypes.RoleTool, ToolCallID: "call-old", Content: "old result"}, - {Role: providertypes.RoleAssistant, Content: "after tool"}, - {Role: providertypes.RoleUser, Content: "instruction to keep"}, - {Role: providertypes.RoleAssistant, Content: "ack"}, - {Role: providertypes.RoleUser, Content: "recent follow up"}, - {Role: providertypes.RoleAssistant, Content: "recent answer"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "latest result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-old", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old result")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("after tool")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("instruction to keep")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("ack")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent follow up")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest result")}}, } result, err := runner.Run(context.Background(), Input{ @@ -266,12 +267,12 @@ func TestAutoCompactUsesManualStrategyAndReportsAutoMode(t *testing.T) { runner.userHomeDir = func() (string, error) { return home, nil } messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "old requirement"}, - {Role: providertypes.RoleAssistant, Content: "old answer"}, - {Role: providertypes.RoleUser, Content: "middle request"}, - {Role: providertypes.RoleAssistant, Content: "middle answer"}, - {Role: providertypes.RoleUser, Content: "recent request"}, - {Role: providertypes.RoleAssistant, Content: "recent answer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old requirement")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("middle request")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("middle answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent request")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent answer")}}, } result, err := runner.Run(context.Background(), Input{ @@ -313,15 +314,15 @@ func TestManualCompactKeepRecentProtectsLatestExplicitUserInstruction(t *testing runner.userHomeDir = func() (string, error) { return t.TempDir(), nil } messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "old requirement"}, - {Role: providertypes.RoleAssistant, Content: "old answer"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "ack"}, - {Role: providertypes.RoleAssistant, Content: "follow up 1"}, - {Role: providertypes.RoleAssistant, Content: "follow up 2"}, - {Role: providertypes.RoleAssistant, Content: "follow up 3"}, - {Role: providertypes.RoleAssistant, Content: "follow up 4"}, - {Role: providertypes.RoleAssistant, Content: "follow up 5"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old requirement")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("ack")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow up 1")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow up 2")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow up 3")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow up 4")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("follow up 5")}}, } result, err := runner.Run(context.Background(), Input{ @@ -347,7 +348,7 @@ func TestManualCompactKeepRecentProtectsLatestExplicitUserInstruction(t *testing if len(generator.calls[0].ArchivedMessages) != 2 || len(generator.calls[0].RetainedMessages) != 7 { t.Fatalf("expected protected tail to start at latest user instruction, got %+v", generator.calls[0]) } - if result.Messages[1].Role != providertypes.RoleUser || result.Messages[1].Content != "latest explicit instruction" { + if result.Messages[1].Role != providertypes.RoleUser || renderTranscriptParts(result.Messages[1].Parts) != "latest explicit instruction" { t.Fatalf("expected retained latest explicit instruction, got %+v", result.Messages[1]) } } @@ -364,7 +365,7 @@ func TestManualCompactWritesTranscriptJSONL(t *testing.T) { SessionID: "session-jsonl", Workdir: filepath.Join(home, "workspace"), Messages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "hello"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyKeepRecent, @@ -404,7 +405,7 @@ func TestManualCompactFailsWhenTranscriptWriteFails(t *testing.T) { Mode: ModeManual, SessionID: "session-fail", Workdir: t.TempDir(), - Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyKeepRecent, ManualKeepRecentMessages: 10, @@ -425,12 +426,12 @@ func TestManualCompactFullReplaceKeepsProtectedTail(t *testing.T) { runner.userHomeDir = func() (string, error) { return home, nil } messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "old requirement"}, - {Role: providertypes.RoleAssistant, Content: "old answer"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old requirement")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "call-old", Name: "filesystem_grep", Arguments: "{}"}}}, - {Role: providertypes.RoleTool, ToolCallID: "call-old", Content: "old result"}, - {Role: providertypes.RoleAssistant, Content: "latest answer"}, + {Role: providertypes.RoleTool, ToolCallID: "call-old", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old result")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest answer")}}, } result, err := runner.Run(context.Background(), Input{ @@ -472,8 +473,8 @@ func TestManualCompactFullReplaceWithoutArchivableMessagesSkipsGenerator(t *test runner.userHomeDir = func() (string, error) { return t.TempDir(), nil } messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "latest answer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest answer")}}, } result, err := runner.Run(context.Background(), Input{ @@ -513,7 +514,7 @@ func TestRunManualRejectsUnsupportedStrategy(t *testing.T) { Mode: ModeManual, SessionID: "session-invalid-strategy", Workdir: t.TempDir(), - Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Config: config.CompactConfig{ ManualStrategy: "unknown_strategy", ManualKeepRecentMessages: 10, @@ -535,7 +536,7 @@ func TestRunRejectsUnsupportedMode(t *testing.T) { Mode: Mode("unexpected"), SessionID: "session-invalid-mode", Workdir: t.TempDir(), - Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyKeepRecent, ManualKeepRecentMessages: 10, @@ -551,8 +552,8 @@ func TestCountMessageCharsUsesRunes(t *testing.T) { t.Parallel() messages := []providertypes.Message{ - {Role: "用户", Content: "你好"}, - {Role: providertypes.RoleAssistant, Content: "done"}, + {Role: "用户", Parts: []providertypes.ContentPart{providertypes.NewTextPart("你好")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}}, } got := countMessageChars(messages) want := len([]rune("用户")) + len([]rune("你好")) + len([]rune(providertypes.RoleAssistant)) + len([]rune("done")) @@ -581,8 +582,8 @@ func TestSaveTranscriptUsesUniqueIDWithinSameTimestamp(t *testing.T) { SessionID: "session-dup-safe", Workdir: t.TempDir(), Messages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "hello"}, - {Role: providertypes.RoleAssistant, Content: "world"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("world")}}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyFullReplace, @@ -630,10 +631,10 @@ func TestManualCompactGeneratorInvalidSummaryFails(t *testing.T) { SessionID: "session-invalid-summary", Workdir: t.TempDir(), Messages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older"}, - {Role: providertypes.RoleAssistant, Content: "older answer"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "newer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("newer")}}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyFullReplace, @@ -678,10 +679,10 @@ func TestManualCompactGeneratorEmptyBulletFails(t *testing.T) { SessionID: "session-empty-bullet", Workdir: t.TempDir(), Messages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older"}, - {Role: providertypes.RoleAssistant, Content: "older answer"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "newer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("newer")}}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyFullReplace, @@ -710,10 +711,10 @@ func TestManualCompactRejectsEmptyTaskState(t *testing.T) { SessionID: "session-empty-task-state", Workdir: t.TempDir(), Messages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older"}, - {Role: providertypes.RoleAssistant, Content: "older answer"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "newer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("newer")}}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyFullReplace, @@ -741,10 +742,10 @@ func TestManualCompactTruncationFailsWhenStructureBreaks(t *testing.T) { SessionID: "session-truncate-fail", Workdir: t.TempDir(), Messages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older"}, - {Role: providertypes.RoleAssistant, Content: "older answer"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "newer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("newer")}}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyFullReplace, @@ -769,7 +770,7 @@ func TestManualCompactKeepRecentWithoutEnoughMessagesSkipsGenerator(t *testing.T SessionID: "session-no-compact", Workdir: t.TempDir(), Messages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "single message"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("single message")}}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyKeepRecent, @@ -799,10 +800,10 @@ func TestManualCompactReturnsErrorWhenSummaryGeneratorIsMissing(t *testing.T) { SessionID: "session-missing-generator", Workdir: t.TempDir(), Messages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older"}, - {Role: providertypes.RoleAssistant, Content: "older answer"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "newer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("newer")}}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyFullReplace, @@ -827,10 +828,10 @@ func TestManualCompactDefaultsToKeepRecentStrategyWhenManualStrategyIsEmpty(t *t SessionID: "session-default-strategy", Workdir: t.TempDir(), Messages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "old request"}, - {Role: providertypes.RoleAssistant, Content: "old answer"}, - {Role: providertypes.RoleUser, Content: "latest request"}, - {Role: providertypes.RoleAssistant, Content: "latest answer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old request")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest request")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest answer")}}, }, Config: config.CompactConfig{ ManualStrategy: "", diff --git a/internal/context/compact/transcript_store.go b/internal/context/compact/transcript_store.go index c4dc7cf6..c4007a5f 100644 --- a/internal/context/compact/transcript_store.go +++ b/internal/context/compact/transcript_store.go @@ -87,7 +87,7 @@ func (s transcriptStore) Save(messages []providertypes.Message, sessionID string Index: i, Timestamp: now, Role: message.Role, - Content: message.Content, + Content: renderTranscriptParts(message.Parts), ToolCalls: append([]providertypes.ToolCall(nil), message.ToolCalls...), ToolCallID: message.ToolCallID, IsError: message.IsError, diff --git a/internal/context/compact/transcript_store_test.go b/internal/context/compact/transcript_store_test.go index 5f9aec8a..9c79cf53 100644 --- a/internal/context/compact/transcript_store_test.go +++ b/internal/context/compact/transcript_store_test.go @@ -26,7 +26,7 @@ func TestTranscriptStoreSaveSanitizesSessionIDAndWritesJSONL(t *testing.T) { } id, path, err := store.Save([]providertypes.Message{ - {Role: providertypes.RoleUser, Content: "hello"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, }, "session with spaces", filepath.Join(home, "workspace")) if err != nil { t.Fatalf("Save() error = %v", err) @@ -49,6 +49,47 @@ func TestTranscriptStoreSaveSanitizesSessionIDAndWritesJSONL(t *testing.T) { } } +func TestTranscriptStoreSaveRedactsImageIdentifiersInContent(t *testing.T) { + t.Parallel() + + home := t.TempDir() + store := transcriptStore{ + now: func() time.Time { return time.Unix(1712052000, 123456789) }, + randomToken: func() (string, error) { return "token1234", nil }, + userHomeDir: func() (string, error) { return home, nil }, + mkdirAll: os.MkdirAll, + writeFile: os.WriteFile, + rename: os.Rename, + remove: os.Remove, + } + + _, path, err := store.Save([]providertypes.Message{ + { + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{ + providertypes.NewTextPart("see "), + providertypes.NewRemoteImagePart("https://example.com/a.png?token=secret"), + providertypes.NewSessionAssetImagePart("asset-123", "image/png"), + }, + }, + }, "session", filepath.Join(home, "workspace")) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read transcript: %v", err) + } + content := string(data) + if strings.Contains(content, "token=secret") || strings.Contains(content, "asset-123") { + t.Fatalf("transcript should redact sensitive image identifiers, got %q", content) + } + if !strings.Contains(content, "[Image:remote]") || !strings.Contains(content, "[Image:session_asset]") { + t.Fatalf("transcript should keep redacted image markers, got %q", content) + } +} + func TestTranscriptFileModeForOS(t *testing.T) { t.Parallel() @@ -113,7 +154,7 @@ func TestTranscriptStoreSaveRemovesTemporaryFileWhenRenameFails(t *testing.T) { }, } - _, _, err := store.Save([]providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, "session", filepath.Join(home, "workspace")) + _, _, err := store.Save([]providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, "session", filepath.Join(home, "workspace")) if err == nil || !strings.Contains(err.Error(), "rename boom") { t.Fatalf("expected rename error, got %v", err) } diff --git a/internal/context/compact_prompt.go b/internal/context/compact_prompt.go index dfef7609..5cd8bb7b 100644 --- a/internal/context/compact_prompt.go +++ b/internal/context/compact_prompt.go @@ -170,7 +170,7 @@ func renderCompactPromptMessages(messages []providertypes.Message) string { builder.WriteString(renderCompactPromptToolCall(call)) } - content := strings.TrimSpace(message.Content) + content := strings.TrimSpace(renderCompactPromptParts(message.Parts)) if content != "" { builder.WriteString("\ncontent:") builder.WriteString(renderCompactPromptContent(content)) diff --git a/internal/context/compact_prompt_test.go b/internal/context/compact_prompt_test.go index 331b789f..d06eb599 100644 --- a/internal/context/compact_prompt_test.go +++ b/internal/context/compact_prompt_test.go @@ -27,8 +27,8 @@ func TestBuildCompactPromptIncludesFixedInstructionsAndBoundaries(t *testing.T) }, ArchivedMessages: []providertypes.Message{ { - Role: providertypes.RoleUser, - Content: "legacy request\nwith details", + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("legacy request\nwith details")}, }, { Role: providertypes.RoleAssistant, @@ -38,7 +38,7 @@ func TestBuildCompactPromptIncludesFixedInstructionsAndBoundaries(t *testing.T) }, }, RetainedMessages: []providertypes.Message{ - {Role: providertypes.RoleAssistant, Content: "recent answer"}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent answer")}}, }, }) @@ -173,3 +173,20 @@ func TestTruncateArchivedContentHandlesTinyBudget(t *testing.T) { t.Fatalf("expected notice prefix slice for tiny budget, got %q", got) } } + +func TestBuildCompactPromptRendersImagePartsAsSafePlaceholders(t *testing.T) { + t.Parallel() + + prompt := BuildCompactPrompt(CompactPromptInput{ + ArchivedMessages: []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewRemoteImagePart("https://example.com/pic.png")}}, + }, + }) + + if !strings.Contains(prompt.UserPrompt, "[Image:remote] https://example.com/pic.png") { + t.Fatalf("expected compact prompt to render remote image placeholder, got %q", prompt.UserPrompt) + } + if strings.Contains(prompt.UserPrompt, "data:image") { + t.Fatalf("compact prompt should not expose binary/data-url payload, got %q", prompt.UserPrompt) + } +} diff --git a/internal/context/internalcompact/messages.go b/internal/context/internalcompact/messages.go index 3574707b..e489d4f8 100644 --- a/internal/context/internalcompact/messages.go +++ b/internal/context/internalcompact/messages.go @@ -72,16 +72,33 @@ func RetainedStartForKeepRecentMessages(spans []MessageSpan, keepMessages int) i return retainedStart } -// lastExplicitUserMessageIndex 返回最后一条非空用户消息的位置,用于保护最近明确指令。 +// lastExplicitUserMessageIndex 返回最后一条显式用户消息的位置,用于保护最近明确指令。 func lastExplicitUserMessageIndex(messages []providertypes.Message) int { for index := len(messages) - 1; index >= 0; index-- { - if messages[index].Role == providertypes.RoleUser && strings.TrimSpace(messages[index].Content) != "" { + if messages[index].Role == providertypes.RoleUser && hasExplicitUserInput(messages[index].Parts) { return index } } return -1 } +// hasExplicitUserInput 判断用户消息是否包含显式输入(非空文本或图片)。 +func hasExplicitUserInput(parts []providertypes.ContentPart) bool { + for _, part := range parts { + switch part.Kind { + case providertypes.ContentPartText: + if strings.TrimSpace(part.Text) != "" { + return true + } + case providertypes.ContentPartImage: + if part.Image != nil { + return true + } + } + } + return false +} + // markSpanProtected 将包含目标消息的分段标记为受保护分段。 func markSpanProtected(spans []MessageSpan, messageIndex int) { for index := range spans { diff --git a/internal/context/internalcompact/messages_test.go b/internal/context/internalcompact/messages_test.go index b561ccec..371465d5 100644 --- a/internal/context/internalcompact/messages_test.go +++ b/internal/context/internalcompact/messages_test.go @@ -10,17 +10,17 @@ func TestBuildMessageSpansPreservesToolBlocksAndProtectedTail(t *testing.T) { t.Parallel() messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "old"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "result"}, - {Role: providertypes.RoleAssistant, Content: "after tool"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "latest answer"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("result")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("after tool")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest answer")}}, } spans := BuildMessageSpans(messages) @@ -56,3 +56,22 @@ func TestRetainedStartForKeepRecentMessagesHonorsProtectedTail(t *testing.T) { t.Fatalf("expected protected tail start 2, got %d", start) } } + +func TestBuildMessageSpansTreatsImageOnlyUserMessageAsExplicitInput(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("ack")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewRemoteImagePart("https://example.com/image.png")}}, + } + + spans := BuildMessageSpans(messages) + protectedStart, ok := ProtectedTailStart(spans) + if !ok { + t.Fatalf("expected protected tail for image-only user message") + } + if protectedStart != 2 { + t.Fatalf("expected protected tail start 2, got %d", protectedStart) + } +} diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index 4ce51368..6627d459 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -58,7 +58,7 @@ func microCompactMessagesWithPolicies(messages []providertypes.Message, policies for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { if shouldClearToolMessage(cloned[messageIndex], compactableIDs) { - cloned[messageIndex].Content = microCompactClearedMessage + cloned[messageIndex].Parts = []providertypes.ContentPart{providertypes.NewTextPart(microCompactClearedMessage)} } } } @@ -150,6 +150,6 @@ func shouldClearToolMessage(message providertypes.Message, compactableIDs map[st return false } - content := strings.TrimSpace(message.Content) + content := strings.TrimSpace(renderDisplayParts(message.Parts)) return content != "" && content != microCompactClearedMessage } diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go index 49b0e4de..6077100a 100644 --- a/internal/context/microcompact_test.go +++ b/internal/context/microcompact_test.go @@ -20,47 +20,47 @@ func TestMicroCompactMessagesClearsOlderCompactableToolResults(t *testing.T) { t.Parallel() messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old read result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old read result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "current working reply"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current working reply")}}, } got := microCompactMessages(messages) if len(got) != len(messages) { t.Fatalf("expected message count to stay unchanged, got %d want %d", len(got), len(messages)) } - if got[2].Content != microCompactClearedMessage { - t.Fatalf("expected oldest compactable tool result to be cleared, got %q", got[2].Content) + if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected oldest compactable tool result to be cleared, got %q", renderDisplayParts(got[2].Parts)) } - if got[4].Content != "recent bash result" { - t.Fatalf("expected recent compactable tool result to be retained, got %q", got[4].Content) + if renderDisplayParts(got[4].Parts) != "recent bash result" { + t.Fatalf("expected recent compactable tool result to be retained, got %q", renderDisplayParts(got[4].Parts)) } - if got[6].Content != "latest webfetch result" { - t.Fatalf("expected latest compactable tool result to be retained, got %q", got[6].Content) + if renderDisplayParts(got[6].Parts) != "latest webfetch result" { + t.Fatalf("expected latest compactable tool result to be retained, got %q", renderDisplayParts(got[6].Parts)) } - if messages[2].Content != "old read result" { - t.Fatalf("expected original slice to remain unchanged, got %q", messages[2].Content) + if renderDisplayParts(messages[2].Parts) != "old read result" { + t.Fatalf("expected original slice to remain unchanged, got %q", renderDisplayParts(messages[2].Parts)) } } @@ -89,50 +89,50 @@ func TestMicroCompactMessagesKeepsProtectedTailUntouched(t *testing.T) { t.Parallel() messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-0", Name: "filesystem_grep", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-0", Content: "old grep result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-0", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old grep result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "recent read result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent read result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "bash", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "tail bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("tail bash result")}}, } got := microCompactMessages(messages) - if got[2].Content != microCompactClearedMessage { - t.Fatalf("expected old tool result before protected tail to be cleared, got %q", got[2].Content) + if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected old tool result before protected tail to be cleared, got %q", renderDisplayParts(got[2].Parts)) } - if got[4].Content != "recent read result" { - t.Fatalf("expected recent tool result before protected tail to remain, got %q", got[4].Content) + if renderDisplayParts(got[4].Parts) != "recent read result" { + t.Fatalf("expected recent tool result before protected tail to remain, got %q", renderDisplayParts(got[4].Parts)) } - if got[6].Content != "recent bash result" { - t.Fatalf("expected second recent tool result before protected tail to remain, got %q", got[6].Content) + if renderDisplayParts(got[6].Parts) != "recent bash result" { + t.Fatalf("expected second recent tool result before protected tail to remain, got %q", renderDisplayParts(got[6].Parts)) } - if got[9].Content != "tail bash result" { - t.Fatalf("expected protected tail tool result to remain, got %q", got[9].Content) + if renderDisplayParts(got[9].Parts) != "tail bash result" { + t.Fatalf("expected protected tail tool result to remain, got %q", renderDisplayParts(got[9].Parts)) } } @@ -146,48 +146,48 @@ func TestMicroCompactMessagesKeepsPreservedToolsErrorsAndOrphans(t *testing.T) { {ID: "call-1", Name: "custom_tool", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "custom result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("custom result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "filesystem_edit", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "edit failed", IsError: true}, - {Role: providertypes.RoleTool, ToolCallID: "orphan", Content: "orphan result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit failed")}, IsError: true}, + {Role: providertypes.RoleTool, ToolCallID: "orphan", Parts: []providertypes.ContentPart{providertypes.NewTextPart("orphan result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "filesystem_write_file", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: microCompactClearedMessage}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart(microCompactClearedMessage)}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-4", Name: "filesystem_grep", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-4", Content: ""}, + {Role: providertypes.RoleTool, ToolCallID: "call-4", Parts: []providertypes.ContentPart{providertypes.NewTextPart("")}}, } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ "custom_tool": tools.MicroCompactPolicyPreserveHistory, }, 0) - if got[1].Content != "custom result" { - t.Fatalf("expected preserved tool result to remain, got %q", got[1].Content) + if renderDisplayParts(got[1].Parts) != "custom result" { + t.Fatalf("expected preserved tool result to remain, got %q", renderDisplayParts(got[1].Parts)) } - if got[3].Content != "edit failed" { - t.Fatalf("expected error tool result to remain, got %q", got[3].Content) + if renderDisplayParts(got[3].Parts) != "edit failed" { + t.Fatalf("expected error tool result to remain, got %q", renderDisplayParts(got[3].Parts)) } - if got[4].Content != "orphan result" { - t.Fatalf("expected orphan tool result to remain, got %q", got[4].Content) + if renderDisplayParts(got[4].Parts) != "orphan result" { + t.Fatalf("expected orphan tool result to remain, got %q", renderDisplayParts(got[4].Parts)) } - if got[6].Content != microCompactClearedMessage { - t.Fatalf("expected already cleared content to remain unchanged, got %q", got[6].Content) + if renderDisplayParts(got[6].Parts) != microCompactClearedMessage { + t.Fatalf("expected already cleared content to remain unchanged, got %q", renderDisplayParts(got[6].Parts)) } - if got[8].Content != "" { - t.Fatalf("expected empty tool result to remain empty, got %q", got[8].Content) + if renderDisplayParts(got[8].Parts) != "" { + t.Fatalf("expected empty tool result to remain empty, got %q", renderDisplayParts(got[8].Parts)) } } @@ -195,7 +195,7 @@ func TestMicroCompactMessagesClearsOnlyNonPreservedResultsInMixedToolSpan(t *tes t.Parallel() messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ @@ -203,34 +203,34 @@ func TestMicroCompactMessagesClearsOnlyNonPreservedResultsInMixedToolSpan(t *tes {ID: "call-2", Name: "custom_tool", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "read result"}, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "custom result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("read result")}}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("custom result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "bash", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-4", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-4", Content: "latest webfetch result"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "current reply"}, + {Role: providertypes.RoleTool, ToolCallID: "call-4", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current reply")}}, } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ "custom_tool": tools.MicroCompactPolicyPreserveHistory, }, 0) - if got[2].Content != microCompactClearedMessage { - t.Fatalf("expected default compactable tool result to be cleared, got %q", got[2].Content) + if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected default compactable tool result to be cleared, got %q", renderDisplayParts(got[2].Parts)) } - if got[3].Content != "custom result" { - t.Fatalf("expected preserved tool result in mixed span to remain, got %q", got[3].Content) + if renderDisplayParts(got[3].Parts) != "custom result" { + t.Fatalf("expected preserved tool result in mixed span to remain, got %q", renderDisplayParts(got[3].Parts)) } if len(got[1].ToolCalls) != 2 { t.Fatalf("expected assistant tool call metadata to remain intact, got %+v", got[1].ToolCalls) @@ -241,34 +241,34 @@ func TestMicroCompactMessagesTreatsNewToolsAsCompactableByDefault(t *testing.T) t.Parallel() messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "repo_search", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old repo search result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old repo search result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 0) - if got[2].Content != microCompactClearedMessage { - t.Fatalf("expected new tool result to be compacted by default, got %q", got[2].Content) + if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected new tool result to be compacted by default, got %q", renderDisplayParts(got[2].Parts)) } } @@ -276,61 +276,61 @@ func TestMicroCompactMessagesSkipsEmptyRecentSpansWhenCountingRetainedBudget(t * t.Parallel() messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "older read result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("older read result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "filesystem_grep", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "middle grep result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("middle grep result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "filesystem_edit", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "near edit result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("near edit result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-4", Name: "bash", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-4", Content: "", IsError: true}, + {Role: providertypes.RoleTool, ToolCallID: "call-4", Parts: []providertypes.ContentPart{providertypes.NewTextPart("")}, IsError: true}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-5", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-5", Content: ""}, - {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, - {Role: providertypes.RoleAssistant, Content: "current reply"}, + {Role: providertypes.RoleTool, ToolCallID: "call-5", Parts: []providertypes.ContentPart{providertypes.NewTextPart("")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current reply")}}, } got := microCompactMessages(messages) - if got[2].Content != microCompactClearedMessage { - t.Fatalf("expected oldest valid tool result to be cleared, got %q", got[2].Content) + if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected oldest valid tool result to be cleared, got %q", renderDisplayParts(got[2].Parts)) } - if got[4].Content != "middle grep result" { - t.Fatalf("expected middle valid tool result to remain, got %q", got[4].Content) + if renderDisplayParts(got[4].Parts) != "middle grep result" { + t.Fatalf("expected middle valid tool result to remain, got %q", renderDisplayParts(got[4].Parts)) } - if got[6].Content != "near edit result" { - t.Fatalf("expected nearer valid tool result to remain, got %q", got[6].Content) + if renderDisplayParts(got[6].Parts) != "near edit result" { + t.Fatalf("expected nearer valid tool result to remain, got %q", renderDisplayParts(got[6].Parts)) } - if got[8].Content != "" { - t.Fatalf("expected error/empty tool result to remain unchanged, got %q", got[8].Content) + if renderDisplayParts(got[8].Parts) != "" { + t.Fatalf("expected error/empty tool result to remain unchanged, got %q", renderDisplayParts(got[8].Parts)) } - if got[10].Content != "" { - t.Fatalf("expected empty recent tool result to remain unchanged, got %q", got[10].Content) + if renderDisplayParts(got[10].Parts) != "" { + t.Fatalf("expected empty recent tool result to remain unchanged, got %q", renderDisplayParts(got[10].Parts)) } } @@ -338,11 +338,11 @@ func TestMicroCompactMessagesSkipsToolMessagesWhenCompactableIDsMissing(t *testi t.Parallel() messages := []providertypes.Message{ - {Role: providertypes.RoleTool, ToolCallID: "orphan", Content: "orphan result"}, + {Role: providertypes.RoleTool, ToolCallID: "orphan", Parts: []providertypes.ContentPart{providertypes.NewTextPart("orphan result")}}, } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 0) - if got[0].Content != "orphan result" { - t.Fatalf("expected orphan tool result to remain, got %q", got[0].Content) + if renderDisplayParts(got[0].Parts) != "orphan result" { + t.Fatalf("expected orphan tool result to remain, got %q", renderDisplayParts(got[0].Parts)) } } diff --git a/internal/context/parts_render.go b/internal/context/parts_render.go new file mode 100644 index 00000000..9c8cb32c --- /dev/null +++ b/internal/context/parts_render.go @@ -0,0 +1,40 @@ +package context + +import ( + "strings" + + "neo-code/internal/partsrender" + providertypes "neo-code/internal/provider/types" +) + +// renderCompactPromptParts 将消息 Parts 渲染为 compact prompt 可消费的文本表示。 +func renderCompactPromptParts(parts []providertypes.ContentPart) string { + return partsrender.RenderCompactPromptParts(parts) +} + +// renderTranscriptParts 将消息 Parts 渲染为 transcript 可审计文本,避免泄露原始二进制。 +func renderTranscriptParts(parts []providertypes.ContentPart) string { + return partsrender.RenderTranscriptParts(parts) +} + +// renderDisplayParts 将消息 Parts 渲染为通用展示文本,供 display/memo 判定与展示使用。 +func renderDisplayParts(parts []providertypes.ContentPart) string { + return partsrender.RenderDisplayParts(parts) +} + +// hasRenderableParts 判断消息是否包含可见语义(非空文本或图片)。 +func hasRenderableParts(parts []providertypes.ContentPart) bool { + for _, part := range parts { + switch part.Kind { + case providertypes.ContentPartText: + if strings.TrimSpace(part.Text) != "" { + return true + } + case providertypes.ContentPartImage: + if part.Image != nil { + return true + } + } + } + return false +} diff --git a/internal/context/parts_render_test.go b/internal/context/parts_render_test.go new file mode 100644 index 00000000..ce987ac3 --- /dev/null +++ b/internal/context/parts_render_test.go @@ -0,0 +1,45 @@ +package context + +import ( + "testing" + + providertypes "neo-code/internal/provider/types" +) + +func TestRenderCompactPromptPartsIncludesImageSourceDetails(t *testing.T) { + t.Parallel() + + parts := []providertypes.ContentPart{ + providertypes.NewTextPart("before"), + providertypes.NewRemoteImagePart("https://example.com/a.png"), + providertypes.NewSessionAssetImagePart("asset-1", "image/png"), + } + got := renderCompactPromptParts(parts) + want := "before[Image:remote] https://example.com/a.png[Image:session_asset] asset-1 (image/png)" + if got != want { + t.Fatalf("renderCompactPromptParts() = %q, want %q", got, want) + } +} + +func TestRenderDisplayPartsUsesSafeImagePlaceholder(t *testing.T) { + t.Parallel() + + parts := []providertypes.ContentPart{ + providertypes.NewTextPart("look"), + providertypes.NewRemoteImagePart("https://example.com/a.png"), + } + if got := renderDisplayParts(parts); got != "look[Image]" { + t.Fatalf("renderDisplayParts() = %q, want %q", got, "look[Image]") + } +} + +func TestHasRenderablePartsTreatsImageAsMeaningfulInput(t *testing.T) { + t.Parallel() + + if hasRenderableParts([]providertypes.ContentPart{providertypes.NewTextPart(" ")}) { + t.Fatalf("blank text part should not be renderable") + } + if !hasRenderableParts([]providertypes.ContentPart{providertypes.NewSessionAssetImagePart("asset-1", "image/jpeg")}) { + t.Fatalf("image part should be renderable") + } +} diff --git a/internal/context/projection.go b/internal/context/projection.go index 85e7ecd0..ac7da4b3 100644 --- a/internal/context/projection.go +++ b/internal/context/projection.go @@ -20,7 +20,7 @@ func ProjectToolMessagesForModel(messages []providertypes.Message) []providertyp if !isProjectableToolMessage(message) { continue } - messages[i].Content = tools.FormatToolMessageForModel(message) + messages[i].Parts = []providertypes.ContentPart{providertypes.NewTextPart(tools.FormatToolMessageForModel(message))} messages[i].ToolMetadata = nil } return messages @@ -144,7 +144,7 @@ func isInjectableToolMessage(message providertypes.Message) bool { if message.Role != providertypes.RoleTool { return false } - content := strings.TrimSpace(message.Content) + content := strings.TrimSpace(renderDisplayParts(message.Parts)) if content == microCompactClearedMessage { return false } @@ -173,7 +173,7 @@ func sanitizeRecentWindowToolMessages(messages []providertypes.Message) []provid if message.Role != providertypes.RoleTool { continue } - messages[index].Content = sanitizeProjectedToolContent(message.Content) + messages[index].Parts = []providertypes.ContentPart{providertypes.NewTextPart(sanitizeProjectedToolContent(renderTranscriptParts(message.Parts)))} } return messages } diff --git a/internal/context/projection_test.go b/internal/context/projection_test.go index 5b8f428a..372e3c3f 100644 --- a/internal/context/projection_test.go +++ b/internal/context/projection_test.go @@ -11,47 +11,47 @@ func TestProjectToolMessagesForModelSkipsMessagesThatCannotBeProjected(t *testin t.Parallel() messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("user")}}, { Role: providertypes.RoleTool, ToolCallID: "call-1", - Content: "tool output", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool output")}, ToolMetadata: nil, }, { Role: providertypes.RoleTool, ToolCallID: "call-2", - Content: " ", + Parts: []providertypes.ContentPart{providertypes.NewTextPart(" ")}, ToolMetadata: map[string]string{"tool_name": "bash"}, }, { Role: providertypes.RoleTool, ToolCallID: "call-3", - Content: microCompactClearedMessage, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(microCompactClearedMessage)}, ToolMetadata: map[string]string{"tool_name": "bash"}, }, { Role: providertypes.RoleTool, ToolCallID: "call-4", - Content: "result", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("result")}, ToolMetadata: map[string]string{"tool_name": "filesystem_read_file", "path": "README.md"}, }, } projected := ProjectToolMessagesForModel(cloneContextMessages(messages)) - if projected[0].Content != "user" { + if renderDisplayParts(projected[0].Parts) != "user" { t.Fatalf("non-tool message should remain unchanged, got %+v", projected[0]) } - if projected[1].Content != "tool output" || projected[1].ToolMetadata != nil { + if renderDisplayParts(projected[1].Parts) != "tool output" || projected[1].ToolMetadata != nil { t.Fatalf("tool without projection metadata should remain unchanged, got %+v", projected[1]) } - if !strings.Contains(projected[2].Content, "tool result") || projected[2].ToolMetadata != nil { + if !strings.Contains(renderDisplayParts(projected[2].Parts), "tool result") || projected[2].ToolMetadata != nil { t.Fatalf("metadata-only tool message should be projected, got %+v", projected[2]) } - if projected[3].Content != microCompactClearedMessage || projected[3].ToolMetadata == nil { + if renderDisplayParts(projected[3].Parts) != microCompactClearedMessage || projected[3].ToolMetadata == nil { t.Fatalf("cleared tool content should not be projected, got %+v", projected[3]) } - if !strings.Contains(projected[4].Content, "tool result") || projected[4].ToolMetadata != nil { + if !strings.Contains(renderDisplayParts(projected[4].Parts), "tool result") || projected[4].ToolMetadata != nil { t.Fatalf("valid tool message should be projected, got %+v", projected[4]) } } @@ -71,13 +71,13 @@ func TestBuildRecentMessagesForModelBoundaries(t *testing.T) { }, { name: "non-positive limit", - messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "x"}}, + messages: []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("x")}}}, limit: 0, }, { name: "no keepable anchor", messages: []providertypes.Message{ - {Role: providertypes.RoleTool, ToolCallID: "orphan", Content: "orphan"}, + {Role: providertypes.RoleTool, ToolCallID: "orphan", Parts: []providertypes.ContentPart{providertypes.NewTextPart("orphan")}}, }, limit: 10, }, @@ -98,7 +98,7 @@ func TestBuildRecentMessagesForModelKeepsOnlyRecentValidAnchors(t *testing.T) { t.Parallel() original := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "old-user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old-user")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ @@ -108,10 +108,10 @@ func TestBuildRecentMessagesForModelKeepsOnlyRecentValidAnchors(t *testing.T) { { Role: providertypes.RoleTool, ToolCallID: "call-1", - Content: "README body", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("README body")}, ToolMetadata: map[string]string{"tool_name": "filesystem_read_file", "path": "README.md"}, }, - {Role: providertypes.RoleUser, Content: "latest-user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest-user")}}, } recent := BuildRecentMessagesForModel(original, 2) @@ -121,21 +121,21 @@ func TestBuildRecentMessagesForModelKeepsOnlyRecentValidAnchors(t *testing.T) { if recent[0].Role != providertypes.RoleAssistant || len(recent[0].ToolCalls) != 1 { t.Fatalf("expected valid tool span to remain, got %+v", recent[0]) } - if recent[1].Role != providertypes.RoleTool || !strings.Contains(recent[1].Content, "tool result") { + if recent[1].Role != providertypes.RoleTool || !strings.Contains(renderDisplayParts(recent[1].Parts), "tool result") { t.Fatalf("expected tool message to be projected, got %+v", recent[1]) } - if strings.Contains(recent[1].Content, "content:\nREADME body") { + if strings.Contains(renderDisplayParts(recent[1].Parts), "content:\nREADME body") { t.Fatalf("expected tool payload to be minimized, got %+v", recent[1]) } - if !strings.Contains(recent[1].Content, "content_excerpt:") { + if !strings.Contains(renderDisplayParts(recent[1].Parts), "content_excerpt:") { t.Fatalf("expected tool payload excerpt marker, got %+v", recent[1]) } - if recent[2].Content != "latest-user" { + if renderDisplayParts(recent[2].Parts) != "latest-user" { t.Fatalf("expected latest user anchor to remain, got %+v", recent[2]) } - recent[1].Content = "changed" - if original[2].Content != "README body" { + recent[1].Parts = []providertypes.ContentPart{providertypes.NewTextPart("changed")} + if renderDisplayParts(original[2].Parts) != "README body" { t.Fatalf("expected original messages to remain unchanged, got %+v", original[2]) } } @@ -154,24 +154,24 @@ func TestBuildRecentMessagesForModelRespectsAbsoluteMessageBudget(t *testing.T) {ID: "call-5", Name: "bash", Arguments: `{}`}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "one", ToolMetadata: map[string]string{"tool_name": "bash"}}, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "two", ToolMetadata: map[string]string{"tool_name": "bash"}}, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "three", ToolMetadata: map[string]string{"tool_name": "bash"}}, - {Role: providertypes.RoleTool, ToolCallID: "call-4", Content: "four", ToolMetadata: map[string]string{"tool_name": "bash"}}, - {Role: providertypes.RoleTool, ToolCallID: "call-5", Content: "five", ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("one")}, ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("two")}, ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("three")}, ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-4", Parts: []providertypes.ContentPart{providertypes.NewTextPart("four")}, ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-5", Parts: []providertypes.ContentPart{providertypes.NewTextPart("five")}, ToolMetadata: map[string]string{"tool_name": "bash"}}, } messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "old-user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("old-user")}}, } messages = append(messages, longSpan...) - messages = append(messages, providertypes.Message{Role: providertypes.RoleUser, Content: "latest-user"}) + messages = append(messages, providertypes.Message{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest-user")}}) recent := BuildRecentMessagesForModel(messages, 3) if len(recent) != 2 { t.Fatalf("len(recent) = %d, want 2", len(recent)) } - if recent[0].Content != "old-user" || recent[1].Content != "latest-user" { + if renderDisplayParts(recent[0].Parts) != "old-user" || renderDisplayParts(recent[1].Parts) != "latest-user" { t.Fatalf("expected oversized tool span to be skipped, got %+v", recent) } } @@ -197,14 +197,14 @@ func TestMatchedToolCallSpanRejectsInvalidAssistantStates(t *testing.T) { t.Parallel() messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("user")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: " ", Name: "bash", Arguments: `{}`}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "tool output"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool output")}}, } if span := matchedToolCallSpan(messages, -1); span != nil { @@ -232,12 +232,12 @@ func TestMatchedToolCallSpanRequiresProjectableResponsesAndSkipsDuplicates(t *te {ID: "call-2", Name: "filesystem_read_file", Arguments: `{}`}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "", ToolMetadata: map[string]string{"tool_name": "bash"}}, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "first result", ToolMetadata: map[string]string{"tool_name": "bash"}}, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "duplicate result", ToolMetadata: map[string]string{"tool_name": "bash"}}, - {Role: providertypes.RoleTool, ToolCallID: "ignored", Content: "ignored result", ToolMetadata: map[string]string{"tool_name": "bash"}}, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "second result", ToolMetadata: map[string]string{"tool_name": "filesystem_read_file"}}, - {Role: providertypes.RoleUser, Content: "after"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("")}, ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("first result")}, ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("duplicate result")}, ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "ignored", Parts: []providertypes.ContentPart{providertypes.NewTextPart("ignored result")}, ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("second result")}, ToolMetadata: map[string]string{"tool_name": "filesystem_read_file"}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("after")}}, } span := matchedToolCallSpan(messages, 0) @@ -262,7 +262,7 @@ func TestMatchedToolCallSpanAcceptsMetadataOnlyResponses(t *testing.T) { { Role: providertypes.RoleTool, ToolCallID: "call-1", - Content: " ", + Parts: []providertypes.ContentPart{providertypes.NewTextPart(" ")}, ToolMetadata: map[string]string{"tool_name": "webfetch", "status_code": "200"}, }, } @@ -283,7 +283,7 @@ func TestMatchedToolCallSpanRejectsResponsesWithoutProjectionMetadata(t *testing {ID: "call-1", Name: "bash", Arguments: `{}`}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "raw result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("raw result")}}, } if span := matchedToolCallSpan(messages, 0); span != nil { @@ -301,27 +301,27 @@ func TestIsInjectableToolMessage(t *testing.T) { }{ { name: "non-tool", - message: providertypes.Message{Role: providertypes.RoleUser, Content: "user"}, + message: providertypes.Message{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("user")}}, want: false, }, { name: "empty", - message: providertypes.Message{Role: providertypes.RoleTool, Content: " "}, + message: providertypes.Message{Role: providertypes.RoleTool, Parts: []providertypes.ContentPart{providertypes.NewTextPart(" ")}}, want: false, }, { name: "metadata-only", - message: providertypes.Message{Role: providertypes.RoleTool, Content: " ", ToolMetadata: map[string]string{"tool_name": "bash"}}, + message: providertypes.Message{Role: providertypes.RoleTool, Parts: []providertypes.ContentPart{providertypes.NewTextPart(" ")}, ToolMetadata: map[string]string{"tool_name": "bash"}}, want: true, }, { name: "cleared", - message: providertypes.Message{Role: providertypes.RoleTool, Content: microCompactClearedMessage}, + message: providertypes.Message{Role: providertypes.RoleTool, Parts: []providertypes.ContentPart{providertypes.NewTextPart(microCompactClearedMessage)}}, want: false, }, { name: "valid", - message: providertypes.Message{Role: providertypes.RoleTool, Content: "ok"}, + message: providertypes.Message{Role: providertypes.RoleTool, Parts: []providertypes.ContentPart{providertypes.NewTextPart("ok")}}, want: true, }, } diff --git a/internal/context/source_skills_test.go b/internal/context/source_skills_test.go index e5e83e9d..dc2389d9 100644 --- a/internal/context/source_skills_test.go +++ b/internal/context/source_skills_test.go @@ -15,7 +15,7 @@ func TestDefaultBuilderBuildInjectsSkillsInStableOrder(t *testing.T) { builder := NewBuilder() result, err := builder.Build(stdcontext.Background(), BuildInput{ - Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, ActiveSkills: []skills.Skill{ { Descriptor: skills.Descriptor{ID: "zeta", Name: "Zeta"}, @@ -76,7 +76,7 @@ func TestDefaultBuilderBuildSkipsSkillsSectionWhenNoActiveSkills(t *testing.T) { builder := NewBuilder() result, err := builder.Build(stdcontext.Background(), BuildInput{ - Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Metadata: testMetadata(t.TempDir()), }) if err != nil { diff --git a/internal/memo/auto_extractor_test.go b/internal/memo/auto_extractor_test.go index a5d7a9dd..5cc9e956 100644 --- a/internal/memo/auto_extractor_test.go +++ b/internal/memo/auto_extractor_test.go @@ -48,7 +48,7 @@ func TestAutoExtractorDebounceMergesRequests(t *testing.T) { svc := newAutoExtractorTestService(t) extractor := &stubMemoExtractor{ extractFn: func(ctx context.Context, messages []providertypes.Message) ([]Entry, error) { - last := messages[len(messages)-1].Content + last := renderMemoParts(messages[len(messages)-1].Parts) return []Entry{{Type: TypeProject, Title: last, Content: last, Source: SourceAutoExtract}}, nil }, } @@ -56,8 +56,8 @@ func TestAutoExtractorDebounceMergesRequests(t *testing.T) { auto.debounce = 20 * time.Millisecond auto.logf = func(string, ...any) {} - auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Content: "first"}}) - auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Content: "second"}}) + auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("first")}}}) + auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("second")}}}) waitFor(t, time.Second, func() bool { return extractor.Calls() == 1 }) time.Sleep(60 * time.Millisecond) @@ -88,14 +88,14 @@ func TestAutoExtractorTrailingRun(t *testing.T) { extractor := &stubMemoExtractor{ extractFn: func(ctx context.Context, messages []providertypes.Message) ([]Entry, error) { - switch messages[len(messages)-1].Content { + switch renderMemoParts(messages[len(messages)-1].Parts) { case "first": firstStarted <- struct{}{} <-releaseFirst case "second": secondStarted <- struct{}{} } - last := messages[len(messages)-1].Content + last := renderMemoParts(messages[len(messages)-1].Parts) return []Entry{{Type: TypeProject, Title: last, Content: last, Source: SourceAutoExtract}}, nil }, } @@ -103,7 +103,7 @@ func TestAutoExtractorTrailingRun(t *testing.T) { auto.debounce = 15 * time.Millisecond auto.logf = func(string, ...any) {} - auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Content: "first"}}) + auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("first")}}}) select { case <-firstStarted: @@ -111,7 +111,7 @@ func TestAutoExtractorTrailingRun(t *testing.T) { t.Fatal("first extraction did not start") } - auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Content: "second"}}) + auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("second")}}}) time.Sleep(40 * time.Millisecond) close(releaseFirst) @@ -139,7 +139,7 @@ func TestAutoExtractorErrorsAreSilent(t *testing.T) { auto.debounce = 10 * time.Millisecond auto.logf = func(string, ...any) {} - auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Content: "x"}}) + auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("x")}}}) waitFor(t, time.Second, func() bool { return extractor.Calls() == 1 }) entries, err := svc.List(context.Background()) @@ -154,10 +154,9 @@ func TestAutoExtractorErrorsAreSilent(t *testing.T) { func TestAutoExtractorSuppressesExactDuplicates(t *testing.T) { svc := newAutoExtractorTestService(t) if err := svc.Add(context.Background(), Entry{ - Type: TypeUser, - Title: "reply in chinese", - Content: "reply in chinese", - Source: SourceAutoExtract, + Type: TypeUser, + Title: "reply in chinese", Content: "reply in chinese", + Source: SourceAutoExtract, }); err != nil { t.Fatalf("seed Add() error = %v", err) } @@ -175,7 +174,7 @@ func TestAutoExtractorSuppressesExactDuplicates(t *testing.T) { auto.debounce = 10 * time.Millisecond auto.logf = func(string, ...any) {} - auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Content: "dedupe"}}) + auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("dedupe")}}}) waitFor(t, time.Second, func() bool { entries, err := svc.List(context.Background()) return err == nil && len(entries) == 2 @@ -208,8 +207,8 @@ func TestAutoExtractorSuppressesExactDuplicatesAcrossSessions(t *testing.T) { auto.debounce = 0 auto.logf = func(string, ...any) {} - auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Content: "one"}}) - auto.Schedule("session-2", []providertypes.Message{{Role: providertypes.RoleUser, Content: "two"}}) + auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("one")}}}) + auto.Schedule("session-2", []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("two")}}}) for i := 0; i < 2; i++ { select { @@ -247,7 +246,7 @@ func TestAutoExtractorRemovesIdleState(t *testing.T) { auto.idleTTL = 20 * time.Millisecond auto.logf = func(string, ...any) {} - auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Content: "cleanup"}}) + auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("cleanup")}}}) waitFor(t, time.Second, func() bool { return extractor.Calls() == 1 }) waitFor(t, time.Second, func() bool { @@ -264,7 +263,7 @@ func TestAutoExtractorHandleIdleKeepsActiveState(t *testing.T) { state := &autoExtractState{ idleSeq: 2, pending: &autoExtractRequest{ - messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "keep"}}, + messages: []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("keep")}}}, }, } @@ -307,7 +306,7 @@ func TestAutoExtractorLoadsDedupIndexOutsideCurrentProcessState(t *testing.T) { auto.debounce = 5 * time.Millisecond auto.logf = func(string, ...any) {} - auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Content: "dedupe after reload"}}) + auto.Schedule("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("dedupe after reload")}}}) waitFor(t, time.Second, func() bool { return extractor.Calls() == 1 }) entries, err := reloaded.List(context.Background()) @@ -336,7 +335,7 @@ func TestAutoExtractorScheduleWithExtractorUsesBoundExtractor(t *testing.T) { auto.debounce = 5 * time.Millisecond auto.logf = func(string, ...any) {} - auto.ScheduleWithExtractor("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Content: "use bound"}}, boundExtractor) + auto.ScheduleWithExtractor("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("use bound")}}}, boundExtractor) waitFor(t, time.Second, func() bool { return boundExtractor.Calls() == 1 }) if defaultExtractor.Calls() != 0 { @@ -351,8 +350,8 @@ func TestAutoExtractorScheduleGuardClauses(t *testing.T) { auto.debounce = 5 * time.Millisecond auto.logf = func(string, ...any) {} - auto.Schedule("", []providertypes.Message{{Role: providertypes.RoleUser, Content: "skip"}}) - auto.ScheduleWithExtractor("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Content: "skip"}}, nil) + auto.Schedule("", []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("skip")}}}) + auto.ScheduleWithExtractor("session-1", []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("skip")}}}, nil) waitFor(t, 150*time.Millisecond, func() bool { return true }) if extractor.Calls() != 0 { @@ -382,8 +381,8 @@ func TestAutoExtractDedupKeyAndTopicParsing(t *testing.T) { func TestCloneProviderMessagesDeepCopyAndStopTimer(t *testing.T) { original := []providertypes.Message{ { - Role: providertypes.RoleAssistant, - Content: "msg", + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("msg")}, ToolCalls: []providertypes.ToolCall{ {ID: "c1", Name: "tool", Arguments: "{}"}, }, diff --git a/internal/memo/extractor.go b/internal/memo/extractor.go index 8f3490fc..0e28e442 100644 --- a/internal/memo/extractor.go +++ b/internal/memo/extractor.go @@ -38,7 +38,7 @@ func (r *RuleExtractor) Extract(ctx context.Context, messages []providertypes.Me var lastUserMsg string for i := len(messages) - 1; i >= 0; i-- { if messages[i].Role == providertypes.RoleUser { - lastUserMsg = messages[i].Content + lastUserMsg = renderMemoParts(messages[i].Parts) break } } diff --git a/internal/memo/extractor_test.go b/internal/memo/extractor_test.go index da4e52d1..6d1f0526 100644 --- a/internal/memo/extractor_test.go +++ b/internal/memo/extractor_test.go @@ -13,9 +13,9 @@ import ( func TestRuleExtractorExtractWithSignal(t *testing.T) { extractor := NewRuleExtractor() messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "请帮我写个函数"}, - {Role: providertypes.RoleAssistant, Content: "好的"}, - {Role: providertypes.RoleUser, Content: "记住以后都用中文注释"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("请帮我写个函数")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("好的")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("记住以后都用中文注释")}}, } entries, err := extractor.Extract(context.Background(), messages) @@ -39,8 +39,8 @@ func TestRuleExtractorExtractWithSignal(t *testing.T) { func TestRuleExtractorExtractNoSignal(t *testing.T) { extractor := NewRuleExtractor() messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "帮我写个排序函数"}, - {Role: providertypes.RoleAssistant, Content: "好的,这是一个快速排序"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("帮我写个排序函数")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("好的,这是一个快速排序")}}, } entries, err := extractor.Extract(context.Background(), messages) @@ -55,7 +55,7 @@ func TestRuleExtractorExtractNoSignal(t *testing.T) { func TestRuleExtractorExtractNoUserMessage(t *testing.T) { extractor := NewRuleExtractor() messages := []providertypes.Message{ - {Role: providertypes.RoleAssistant, Content: "好的"}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("好的")}}, } entries, err := extractor.Extract(context.Background(), messages) @@ -84,7 +84,7 @@ func TestRuleExtractorExtractCancelledContext(t *testing.T) { cancel() _, err := extractor.Extract(ctx, []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "记住这个"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("记住这个")}}, }) if err == nil { t.Error("expected error for cancelled context") @@ -111,7 +111,7 @@ func TestRuleExtractorExtractEnglishSignals(t *testing.T) { for _, tt := range tests { t.Run(tt.content, func(t *testing.T) { messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: tt.content}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart(tt.content)}}, } entries, _ := extractor.Extract(context.Background(), messages) got := len(entries) > 0 @@ -127,7 +127,7 @@ func TestRuleExtractorExtractLongContent(t *testing.T) { // 超过 150 rune,验证按 rune 截断且保持 UTF-8 合法。 longContent := "记住" + strings.Repeat("中", 200) messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: longContent}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart(longContent)}}, } entries, err := extractor.Extract(context.Background(), messages) @@ -151,9 +151,9 @@ func TestRuleExtractorExtractLongContent(t *testing.T) { func TestRuleExtractorExtractOnlyLastUserMessage(t *testing.T) { extractor := NewRuleExtractor() messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "记住偏好A"}, - {Role: providertypes.RoleAssistant, Content: "好的"}, - {Role: providertypes.RoleUser, Content: "写个函数"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("记住偏好A")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("好的")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("写个函数")}}, } entries, _ := extractor.Extract(context.Background(), messages) @@ -209,7 +209,7 @@ func TestExtractAndStore(t *testing.T) { store := NewFileStore(t.TempDir(), t.TempDir()) svc := NewService(store, nil, config.MemoConfig{MaxIndexLines: 200}, nil) messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "写个函数"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("写个函数")}}, } ExtractAndStore(context.Background(), NewRuleExtractor(), svc, messages) entries, _ := svc.List(context.Background()) @@ -222,7 +222,7 @@ func TestExtractAndStore(t *testing.T) { store := NewFileStore(t.TempDir(), t.TempDir()) svc := NewService(store, nil, config.MemoConfig{MaxIndexLines: 200}, nil) messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "记住以后都用中文注释"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("记住以后都用中文注释")}}, } ExtractAndStore(context.Background(), NewRuleExtractor(), svc, messages) entries, _ := svc.List(context.Background()) diff --git a/internal/memo/llm_extractor.go b/internal/memo/llm_extractor.go index e46bd31f..ed2a6ef5 100644 --- a/internal/memo/llm_extractor.go +++ b/internal/memo/llm_extractor.go @@ -109,7 +109,7 @@ func buildExtractionPrompt(now time.Time) string { // containsUserMessage 检查待提取消息中是否包含用户输入。 func containsUserMessage(messages []providertypes.Message) bool { for _, message := range messages { - if message.Role == providertypes.RoleUser && strings.TrimSpace(message.Content) != "" { + if message.Role == providertypes.RoleUser && hasMemoRelevantUserInput(message.Parts) { return true } } diff --git a/internal/memo/llm_extractor_test.go b/internal/memo/llm_extractor_test.go index 10bc7864..43d82291 100644 --- a/internal/memo/llm_extractor_test.go +++ b/internal/memo/llm_extractor_test.go @@ -43,8 +43,8 @@ func TestLLMExtractorExtractValidJSON(t *testing.T) { } entries, err := extractor.Extract(context.Background(), []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "以后默认按 Go 惯用风格写。"}, - {Role: providertypes.RoleAssistant, Content: "收到。"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("以后默认按 Go 惯用风格写。")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("收到。")}}, }) if err != nil { t.Fatalf("Extract() error = %v", err) @@ -86,7 +86,7 @@ func TestLLMExtractorExtractEmptyResult(t *testing.T) { extractor := NewLLMExtractor(&stubTextGenerator{response: `[]`}) entries, err := extractor.Extract(context.Background(), []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "这轮没有需要记住的内容。"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("这轮没有需要记住的内容。")}}, }) if err != nil { t.Fatalf("Extract() error = %v", err) @@ -102,7 +102,7 @@ func TestLLMExtractorExtractNoUserMessage(t *testing.T) { extractor := NewLLMExtractor(generator) entries, err := extractor.Extract(context.Background(), []providertypes.Message{ - {Role: providertypes.RoleAssistant, Content: "只有助手消息。"}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("只有助手消息。")}}, }) if err != nil { t.Fatalf("Extract() error = %v", err) @@ -133,7 +133,7 @@ func TestLLMExtractorExtractInvalidJSON(t *testing.T) { extractor := NewLLMExtractor(&stubTextGenerator{response: `[{invalid json}]`}) _, err := extractor.Extract(context.Background(), []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "记住这个。"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("记住这个。")}}, }) if err == nil { t.Fatal("expected invalid JSON error") @@ -147,7 +147,7 @@ func TestLLMExtractorExtractToleratesWrappedJSON(t *testing.T) { }) entries, err := extractor.Extract(context.Background(), []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "以后改完先跑测试。"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("以后改完先跑测试。")}}, }) if err != nil { t.Fatalf("Extract() error = %v", err) @@ -168,7 +168,7 @@ func TestLLMExtractorExtractFiltersInvalidEntries(t *testing.T) { }) entries, err := extractor.Extract(context.Background(), []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "参考文档在 docs/runtime-provider-event-flow.md。"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("参考文档在 docs/runtime-provider-event-flow.md。")}}, }) if err != nil { t.Fatalf("Extract() error = %v", err) @@ -186,7 +186,7 @@ func TestLLMExtractorExtractCancelledContext(t *testing.T) { cancel() _, err := extractor.Extract(ctx, []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "记住这个。"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("记住这个。")}}, }) if !errors.Is(err, context.Canceled) { t.Fatalf("Extract() error = %v, want context.Canceled", err) @@ -204,13 +204,13 @@ func TestLLMExtractorExtractUsesRecentNonToolMessages(t *testing.T) { messages := make([]providertypes.Message, 0, 16) for index := 0; index < 12; index++ { messages = append(messages, providertypes.Message{ - Role: providertypes.RoleUser, - Content: "user-" + string(rune('a'+index)), + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("user-" + string(rune('a'+index)))}, }) if index%3 == 0 { messages = append(messages, providertypes.Message{ - Role: providertypes.RoleTool, - Content: "tool-" + string(rune('a'+index)), + Role: providertypes.RoleTool, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool-" + string(rune('a'+index)))}, }) } } @@ -227,8 +227,11 @@ func TestLLMExtractorExtractUsesRecentNonToolMessages(t *testing.T) { t.Fatalf("unexpected tool message in extraction context: %#v", message) } } - if generator.messages[0].Content != "user-c" || generator.messages[9].Content != "user-l" { - t.Fatalf("unexpected recent window: first=%q last=%q", generator.messages[0].Content, generator.messages[9].Content) + if renderMemoParts(generator.messages[0].Parts) != "user-c" || + renderMemoParts(generator.messages[9].Parts) != "user-l" { + t.Fatalf("unexpected recent window: first=%q last=%q", + renderMemoParts(generator.messages[0].Parts), + renderMemoParts(generator.messages[9].Parts)) } } @@ -237,14 +240,14 @@ func TestLLMExtractorExtractDropsIncompleteToolCallSpan(t *testing.T) { extractor := NewLLMExtractor(generator) _, err := extractor.Extract(context.Background(), []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "first"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("first")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call_1", Name: "filesystem_read_file", Arguments: `{}`}, }, }, - {Role: providertypes.RoleUser, Content: "second"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("second")}}, }) if err != nil { t.Fatalf("Extract() error = %v", err) @@ -264,7 +267,7 @@ func TestLLMExtractorExtractKeepsProjectedToolCallSpan(t *testing.T) { extractor := NewLLMExtractor(generator) _, err := extractor.Extract(context.Background(), []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "remember this"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("remember this")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ @@ -274,7 +277,7 @@ func TestLLMExtractorExtractKeepsProjectedToolCallSpan(t *testing.T) { { Role: providertypes.RoleTool, ToolCallID: "call_1", - Content: "README body", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("README body")}, ToolMetadata: map[string]string{"tool_name": "filesystem_read_file", "path": "README.md"}, }, }) @@ -291,8 +294,9 @@ func TestLLMExtractorExtractKeepsProjectedToolCallSpan(t *testing.T) { if toolMessage.Role != providertypes.RoleTool { t.Fatalf("expected projected tool message, got %#v", toolMessage) } - if !strings.Contains(toolMessage.Content, "tool result") || !strings.Contains(toolMessage.Content, "tool: filesystem_read_file") { - t.Fatalf("expected projected tool text, got %q", toolMessage.Content) + toolText := renderMemoParts(toolMessage.Parts) + if !strings.Contains(toolText, "tool result") || !strings.Contains(toolText, "tool: filesystem_read_file") { + t.Fatalf("expected projected tool text, got %q", toolText) } if toolMessage.ToolMetadata != nil { t.Fatalf("expected projected tool metadata to be cleared, got %#v", toolMessage.ToolMetadata) @@ -304,7 +308,7 @@ func TestLLMExtractorExtractKeepsMetadataOnlyToolCallSpan(t *testing.T) { extractor := NewLLMExtractor(generator) _, err := extractor.Extract(context.Background(), []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "remember this"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("remember this")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ @@ -314,7 +318,6 @@ func TestLLMExtractorExtractKeepsMetadataOnlyToolCallSpan(t *testing.T) { { Role: providertypes.RoleTool, ToolCallID: "call_1", - Content: "", ToolMetadata: map[string]string{"tool_name": "filesystem_read_file", "path": "README.md"}, }, }) @@ -328,13 +331,14 @@ func TestLLMExtractorExtractKeepsMetadataOnlyToolCallSpan(t *testing.T) { if toolMessage.Role != providertypes.RoleTool { t.Fatalf("expected projected tool message, got %#v", toolMessage) } - if !strings.Contains(toolMessage.Content, "tool result") || - !strings.Contains(toolMessage.Content, "tool: filesystem_read_file") || - !strings.Contains(toolMessage.Content, "meta.path: README.md") { - t.Fatalf("expected metadata-only tool text, got %q", toolMessage.Content) + toolText := renderMemoParts(toolMessage.Parts) + if !strings.Contains(toolText, "tool result") || + !strings.Contains(toolText, "tool: filesystem_read_file") || + !strings.Contains(toolText, "meta.path: README.md") { + t.Fatalf("expected metadata-only tool text, got %q", toolText) } - if strings.Contains(toolMessage.Content, "content:\n") { - t.Fatalf("expected metadata-only projection to omit content section, got %q", toolMessage.Content) + if strings.Contains(toolText, "content:\n") { + t.Fatalf("expected metadata-only projection to omit content section, got %q", toolText) } if toolMessage.ToolMetadata != nil { t.Fatalf("expected projected tool metadata to be cleared, got %#v", toolMessage.ToolMetadata) @@ -346,16 +350,16 @@ func TestLLMExtractorExtractSkipsOrphanAndClearedToolMessages(t *testing.T) { extractor := NewLLMExtractor(generator) _, err := extractor.Extract(context.Background(), []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "alpha"}, - {Role: providertypes.RoleTool, ToolCallID: "orphan", Content: "orphan result"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("alpha")}}, + {Role: providertypes.RoleTool, ToolCallID: "orphan", Parts: []providertypes.ContentPart{providertypes.NewTextPart("orphan result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call_1", Name: "bash", Arguments: `{}`}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call_1", Content: "[Old tool result content cleared]"}, - {Role: providertypes.RoleUser, Content: "beta"}, + {Role: providertypes.RoleTool, ToolCallID: "call_1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("[Old tool result content cleared]")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("beta")}}, }) if err != nil { t.Fatalf("Extract() error = %v", err) @@ -373,7 +377,7 @@ func TestLLMExtractorExtractSkipsOrphanAndClearedToolMessages(t *testing.T) { func TestLLMExtractorExtractNilGenerator(t *testing.T) { var extractor *LLMExtractor _, err := extractor.Extract(context.Background(), []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "记住这个。"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("记住这个。")}}, }) if err == nil || !strings.Contains(err.Error(), "text generator is nil") { t.Fatalf("Extract() error = %v", err) @@ -381,7 +385,7 @@ func TestLLMExtractorExtractNilGenerator(t *testing.T) { extractor = NewLLMExtractor(nil) _, err = extractor.Extract(context.Background(), []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "记住这个。"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("记住这个。")}}, }) if err == nil || !strings.Contains(err.Error(), "text generator is nil") { t.Fatalf("Extract() error = %v", err) @@ -391,7 +395,7 @@ func TestLLMExtractorExtractNilGenerator(t *testing.T) { func TestLLMExtractorExtractGeneratorFailure(t *testing.T) { extractor := NewLLMExtractor(&stubTextGenerator{err: errors.New("upstream failed")}) _, err := extractor.Extract(context.Background(), []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "记住这个。"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("记住这个。")}}, }) if err == nil || !strings.Contains(err.Error(), "upstream failed") { t.Fatalf("Extract() error = %v", err) @@ -406,3 +410,21 @@ func TestExtractJSONArrayErrors(t *testing.T) { t.Fatalf("expected incomplete array error, got %v", err) } } + +func TestLLMExtractorExtractImageOnlyUserMessageSkipsGenerator(t *testing.T) { + generator := &stubTextGenerator{response: `[]`} + extractor := NewLLMExtractor(generator) + + entries, err := extractor.Extract(context.Background(), []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewRemoteImagePart("https://example.com/pic.png")}}, + }) + if err != nil { + t.Fatalf("Extract() error = %v", err) + } + if len(entries) != 0 { + t.Fatalf("len(entries) = %d, want 0", len(entries)) + } + if generator.calls != 0 { + t.Fatalf("Generate() calls = %d, want 0", generator.calls) + } +} diff --git a/internal/memo/message_clone.go b/internal/memo/message_clone.go index 5c5061c9..e036d517 100644 --- a/internal/memo/message_clone.go +++ b/internal/memo/message_clone.go @@ -5,6 +5,7 @@ import providertypes "neo-code/internal/provider/types" // cloneProviderMessage 深拷贝消息结构,避免后台流程读取到运行时后续修改。 func cloneProviderMessage(message providertypes.Message) providertypes.Message { cloned := message + cloned.Parts = providertypes.CloneParts(message.Parts) if len(message.ToolCalls) > 0 { cloned.ToolCalls = append([]providertypes.ToolCall(nil), message.ToolCalls...) } diff --git a/internal/memo/message_clone_test.go b/internal/memo/message_clone_test.go index 8b099c2e..da2ff121 100644 --- a/internal/memo/message_clone_test.go +++ b/internal/memo/message_clone_test.go @@ -32,8 +32,8 @@ func TestCloneProviderMessageDeepCopy(t *testing.T) { func TestCloneProviderMessageHandlesEmptyCollections(t *testing.T) { t.Parallel() - cloned := cloneProviderMessage(providertypes.Message{Role: providertypes.RoleUser, Content: "hi"}) - if cloned.Role != providertypes.RoleUser || cloned.Content != "hi" { + cloned := cloneProviderMessage(providertypes.Message{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}) + if cloned.Role != providertypes.RoleUser || renderMemoParts(cloned.Parts) != "hi" { t.Fatalf("unexpected cloned message %+v", cloned) } if cloned.ToolCalls != nil { diff --git a/internal/memo/parts_projection.go b/internal/memo/parts_projection.go new file mode 100644 index 00000000..eb7cbd04 --- /dev/null +++ b/internal/memo/parts_projection.go @@ -0,0 +1,23 @@ +package memo + +import ( + "strings" + + "neo-code/internal/partsrender" + providertypes "neo-code/internal/provider/types" +) + +// renderMemoParts 将消息 Parts 投影为 memo 可消费文本;图片转为占位符。 +func renderMemoParts(parts []providertypes.ContentPart) string { + return partsrender.RenderDisplayParts(parts) +} + +// hasMemoRelevantUserInput 判断用户消息是否包含可用于 memo 分析的输入。 +func hasMemoRelevantUserInput(parts []providertypes.ContentPart) bool { + for _, part := range parts { + if part.Kind == providertypes.ContentPartText && strings.TrimSpace(part.Text) != "" { + return true + } + } + return false +} diff --git a/internal/memo/parts_projection_test.go b/internal/memo/parts_projection_test.go new file mode 100644 index 00000000..30735e7f --- /dev/null +++ b/internal/memo/parts_projection_test.go @@ -0,0 +1,36 @@ +package memo + +import ( + "testing" + + providertypes "neo-code/internal/provider/types" +) + +func TestRenderMemoPartsUsesImagePlaceholder(t *testing.T) { + t.Parallel() + + parts := []providertypes.ContentPart{ + providertypes.NewTextPart("note:"), + providertypes.NewRemoteImagePart("https://example.com/img.png"), + } + if got := renderMemoParts(parts); got != "note:[Image]" { + t.Fatalf("renderMemoParts() = %q, want %q", got, "note:[Image]") + } +} + +func TestHasMemoRelevantUserInputRequiresNonEmptyText(t *testing.T) { + t.Parallel() + + if hasMemoRelevantUserInput([]providertypes.ContentPart{providertypes.NewTextPart(" ")}) { + t.Fatalf("blank text should not be treated as meaningful input") + } + if hasMemoRelevantUserInput([]providertypes.ContentPart{providertypes.NewRemoteImagePart("https://example.com/img.png")}) { + t.Fatalf("image-only input should not trigger memo extraction") + } + if !hasMemoRelevantUserInput([]providertypes.ContentPart{ + providertypes.NewRemoteImagePart("https://example.com/img.png"), + providertypes.NewTextPart("caption"), + }) { + t.Fatalf("non-empty text should be treated as meaningful user input") + } +} diff --git a/internal/partsrender/render.go b/internal/partsrender/render.go new file mode 100644 index 00000000..2281b480 --- /dev/null +++ b/internal/partsrender/render.go @@ -0,0 +1,87 @@ +package partsrender + +import ( + "strings" + + providertypes "neo-code/internal/provider/types" +) + +type imageRenderMode string + +const ( + imageRenderModeCompactPrompt imageRenderMode = "compact_prompt" + imageRenderModeTranscript imageRenderMode = "transcript" + imageRenderModeDisplay imageRenderMode = "display" +) + +// RenderCompactPromptParts 将消息 Parts 渲染为 compact prompt 可消费的文本表示。 +func RenderCompactPromptParts(parts []providertypes.ContentPart) string { + return renderParts(parts, imageRenderModeCompactPrompt) +} + +// RenderTranscriptParts 将消息 Parts 渲染为 transcript 可审计文本,避免泄露原始二进制。 +func RenderTranscriptParts(parts []providertypes.ContentPart) string { + return renderParts(parts, imageRenderModeTranscript) +} + +// RenderDisplayParts 将消息 Parts 渲染为通用展示文本,图片只保留安全占位。 +func RenderDisplayParts(parts []providertypes.ContentPart) string { + return renderParts(parts, imageRenderModeDisplay) +} + +// renderParts 按场景将多模态 parts 收敛为可读文本;文本原样保留,图片转为占位符。 +func renderParts(parts []providertypes.ContentPart, mode imageRenderMode) string { + var builder strings.Builder + for _, part := range parts { + switch part.Kind { + case providertypes.ContentPartText: + builder.WriteString(part.Text) + case providertypes.ContentPartImage: + builder.WriteString(renderImagePlaceholder(part.Image, mode)) + } + } + return builder.String() +} + +// renderImagePlaceholder 按不同读取场景输出图片占位文本,避免泄露原始图片数据。 +func renderImagePlaceholder(image *providertypes.ImagePart, mode imageRenderMode) string { + if image == nil { + return "[Image]" + } + + switch image.SourceType { + case providertypes.ImageSourceRemote: + if mode == imageRenderModeDisplay { + return "[Image]" + } + if mode == imageRenderModeTranscript { + return "[Image:remote]" + } + url := strings.TrimSpace(image.URL) + if url == "" { + return "[Image]" + } + return "[Image:remote] " + url + case providertypes.ImageSourceSessionAsset: + if mode == imageRenderModeDisplay { + return "[Image]" + } + if mode == imageRenderModeTranscript { + return "[Image:session_asset]" + } + if image.Asset == nil { + return "[Image]" + } + assetID := strings.TrimSpace(image.Asset.ID) + mime := strings.TrimSpace(image.Asset.MimeType) + if assetID == "" { + return "[Image]" + } + if mime == "" { + return "[Image:session_asset] " + assetID + } + return "[Image:session_asset] " + assetID + " (" + mime + ")" + default: + return "[Image]" + } +} diff --git a/internal/partsrender/render_test.go b/internal/partsrender/render_test.go new file mode 100644 index 00000000..73f233e8 --- /dev/null +++ b/internal/partsrender/render_test.go @@ -0,0 +1,142 @@ +package partsrender + +import ( + "testing" + + providertypes "neo-code/internal/provider/types" +) + +func TestRenderCompactPromptParts(t *testing.T) { + t.Parallel() + + parts := []providertypes.ContentPart{ + providertypes.NewTextPart("before"), + providertypes.NewRemoteImagePart("https://example.com/a.png"), + providertypes.NewSessionAssetImagePart("asset-1", "image/png"), + } + + got := RenderCompactPromptParts(parts) + want := "before[Image:remote] https://example.com/a.png[Image:session_asset] asset-1 (image/png)" + if got != want { + t.Fatalf("RenderCompactPromptParts() = %q, want %q", got, want) + } +} + +func TestRenderTranscriptParts(t *testing.T) { + t.Parallel() + + parts := []providertypes.ContentPart{ + providertypes.NewTextPart("look "), + providertypes.NewRemoteImagePart("https://example.com/a.png?token=secret"), + providertypes.NewSessionAssetImagePart("asset-2", ""), + } + + got := RenderTranscriptParts(parts) + want := "look [Image:remote][Image:session_asset]" + if got != want { + t.Fatalf("RenderTranscriptParts() = %q, want %q", got, want) + } +} + +func TestRenderDisplayParts(t *testing.T) { + t.Parallel() + + parts := []providertypes.ContentPart{ + providertypes.NewTextPart("look"), + providertypes.NewRemoteImagePart("https://example.com/a.png"), + } + + got := RenderDisplayParts(parts) + want := "look[Image]" + if got != want { + t.Fatalf("RenderDisplayParts() = %q, want %q", got, want) + } +} + +func TestRenderPartsFallbackForUnknownImageSource(t *testing.T) { + t.Parallel() + + parts := []providertypes.ContentPart{ + { + Kind: providertypes.ContentPartImage, + Image: &providertypes.ImagePart{ + SourceType: providertypes.ImageSourceType("unknown"), + }, + }, + } + + if got := RenderDisplayParts(parts); got != "[Image]" { + t.Fatalf("RenderDisplayParts() fallback = %q, want %q", got, "[Image]") + } +} + +func TestRenderCompactPromptPartsImageFallbackBranches(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + parts []providertypes.ContentPart + want string + }{ + { + name: "nil image payload", + parts: []providertypes.ContentPart{{ + Kind: providertypes.ContentPartImage, + Image: nil, + }}, + want: "[Image]", + }, + { + name: "remote image with empty url", + parts: []providertypes.ContentPart{{ + Kind: providertypes.ContentPartImage, + Image: &providertypes.ImagePart{ + SourceType: providertypes.ImageSourceRemote, + URL: " ", + }, + }}, + want: "[Image]", + }, + { + name: "session asset without asset payload", + parts: []providertypes.ContentPart{{ + Kind: providertypes.ContentPartImage, + Image: &providertypes.ImagePart{ + SourceType: providertypes.ImageSourceSessionAsset, + Asset: nil, + }, + }}, + want: "[Image]", + }, + { + name: "session asset without id", + parts: []providertypes.ContentPart{{ + Kind: providertypes.ContentPartImage, + Image: &providertypes.ImagePart{ + SourceType: providertypes.ImageSourceSessionAsset, + Asset: &providertypes.AssetRef{ID: " ", MimeType: "image/png"}, + }, + }}, + want: "[Image]", + }, + { + name: "session asset without mime", + parts: []providertypes.ContentPart{{ + Kind: providertypes.ContentPartImage, + Image: &providertypes.ImagePart{ + SourceType: providertypes.ImageSourceSessionAsset, + Asset: &providertypes.AssetRef{ID: "asset-3", MimeType: ""}, + }, + }}, + want: "[Image:session_asset] asset-3", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := RenderCompactPromptParts(tc.parts); got != tc.want { + t.Fatalf("RenderCompactPromptParts() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/provider/generate_test.go b/internal/provider/generate_test.go index 89321e80..73649d12 100644 --- a/internal/provider/generate_test.go +++ b/internal/provider/generate_test.go @@ -41,7 +41,7 @@ func TestGenerateTextSuccess(t *testing.T) { Model: "test-model", SystemPrompt: "test prompt", Messages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "test message"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("test message")}}, }, } diff --git a/internal/provider/openaicompat/chatcompletions/provider_test.go b/internal/provider/openaicompat/chatcompletions/provider_test.go index 36ca4a79..0b398dec 100644 --- a/internal/provider/openaicompat/chatcompletions/provider_test.go +++ b/internal/provider/openaicompat/chatcompletions/provider_test.go @@ -42,9 +42,9 @@ func TestNewAndBuildRequest(t *testing.T) { Model: "gpt-5.4", SystemPrompt: "system", Messages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "hello"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "call_1", Name: "filesystem_edit", Arguments: `{"path":"main.go"}`}}}, - {Role: providertypes.RoleTool, ToolCallID: "call_1", Content: "done"}, + {Role: providertypes.RoleTool, ToolCallID: "call_1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}}, }, Tools: []providertypes.ToolSpec{{Name: "filesystem_edit", Description: "Edit file", Schema: map[string]any{"type": "object"}}}, }) @@ -63,7 +63,7 @@ func TestNewAndBuildRequest(t *testing.T) { fallback, err := BuildRequest(testCfg("https://api.example.com/v1", "gpt-4.1", "test-key"), providertypes.GenerateRequest{ SystemPrompt: " ", - Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Tools: []providertypes.ToolSpec{}, }) if err != nil { @@ -77,25 +77,93 @@ func TestNewAndBuildRequest(t *testing.T) { t.Run("message conversion", func(t *testing.T) { t.Parallel() - user := ToOpenAIMessage(providertypes.Message{Role: providertypes.RoleUser, Content: "hello"}) + user, err := ToOpenAIMessage(providertypes.Message{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}) + if err != nil { + t.Fatalf("ToOpenAIMessage() user error = %v", err) + } if user.Role != providertypes.RoleUser || user.Content != "hello" || len(user.ToolCalls) != 0 { t.Fatalf("unexpected user message: %+v", user) } - assistant := ToOpenAIMessage(providertypes.Message{ + multiText, err := ToOpenAIMessage(providertypes.Message{ + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{ + providertypes.NewTextPart("hello "), + providertypes.NewTextPart("world"), + }, + }) + if err != nil { + t.Fatalf("ToOpenAIMessage() multiText error = %v", err) + } + if multiText.Content != "hello world" { + t.Fatalf("unexpected multiText content: %+v", multiText.Content) + } + + assistant, err := ToOpenAIMessage(providertypes.Message{ Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call_1", Name: "read_file", Arguments: `{"path":"main.go"}`}, }, }) + if err != nil { + t.Fatalf("ToOpenAIMessage() assistant error = %v", err) + } if len(assistant.ToolCalls) != 1 || assistant.ToolCalls[0].Type != "function" || assistant.ToolCalls[0].Function.Name != "read_file" { t.Fatalf("unexpected assistant message: %+v", assistant) } - tool := ToOpenAIMessage(providertypes.Message{Role: providertypes.RoleTool, ToolCallID: "call_1", Content: "result"}) + tool, err := ToOpenAIMessage(providertypes.Message{Role: providertypes.RoleTool, ToolCallID: "call_1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("result")}}) + if err != nil { + t.Fatalf("ToOpenAIMessage() tool error = %v", err) + } if tool.Role != providertypes.RoleTool || tool.ToolCallID != "call_1" { t.Fatalf("unexpected tool message: %+v", tool) } + + multiModal, err := ToOpenAIMessage(providertypes.Message{ + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{ + providertypes.NewTextPart("look"), + providertypes.NewRemoteImagePart("https://example.com/img.png"), + }, + }) + if err != nil { + t.Fatalf("ToOpenAIMessage() multiModal error = %v", err) + } + parts, ok := multiModal.Content.([]MessageContentPart) + if !ok || len(parts) != 2 || parts[0].Type != "text" || parts[1].Type != "image_url" || parts[1].ImageURL.URL != "https://example.com/img.png" { + t.Fatalf("unexpected multiModal message: %+v", multiModal) + } + + _, err = ToOpenAIMessage(providertypes.Message{ + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{ + providertypes.NewSessionAssetImagePart("asset-1", "image/png"), + }, + }) + if err == nil || !strings.Contains(err.Error(), "session_asset image is not supported") { + t.Fatalf("expected session_asset error, got %v", err) + } + + _, err = ToOpenAIMessage(providertypes.Message{ + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{ + {Kind: providertypes.ContentPartImage, Image: &providertypes.ImagePart{SourceType: "unknown"}}, + }, + }) + if err == nil || !strings.Contains(err.Error(), "unsupported source type") { + t.Fatalf("expected unsupported image error, got %v", err) + } + + _, err = ToOpenAIMessage(providertypes.Message{ + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{ + providertypes.NewRemoteImagePart(""), + }, + }) + if err == nil || !strings.Contains(err.Error(), "invalid message parts") { + t.Fatalf("expected invalid parts error, got %v", err) + } }) } @@ -304,6 +372,14 @@ func testCfg(baseURL, model, apiKey string) provider.RuntimeConfig { } } +func userTextGenerateRequest(text string) providertypes.GenerateRequest { + return providertypes.GenerateRequest{ + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart(text)}}, + }, + } +} + func mustProvider(t *testing.T) *Provider { t.Helper() p, err := New(testCfg("https://api.example.com/v1", "gpt-4.1", "test-key"), &http.Client{}) @@ -349,11 +425,7 @@ func mustDone(t *testing.T, evt providertypes.StreamEvent) providertypes.Message return payload } -func ioNopCloser(body string) io.ReadCloser { return &readCloser{Reader: strings.NewReader(body)} } - -type readCloser struct{ *strings.Reader } - -func (r *readCloser) Close() error { return nil } +func ioNopCloser(body string) io.ReadCloser { return io.NopCloser(strings.NewReader(body)) } type failingReadCloser struct{ err error } @@ -495,7 +567,7 @@ func TestGenerateVariants(t *testing.T) { } if err := mustProvider(t).Generate(context.Background(), providertypes.GenerateRequest{ - Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + Messages: userTextGenerateRequest("hello").Messages, Tools: []providertypes.ToolSpec{{Name: "bad", Schema: map[string]any{"bad": func() {}}}}, }, nil); err == nil || !strings.Contains(err.Error(), "marshal request") { t.Fatalf("expected marshal error, got %v", err) @@ -506,9 +578,7 @@ func TestGenerateVariants(t *testing.T) { t.Parallel() invalidURL := &Provider{cfg: testCfg("://bad", "gpt-4.1", "test-key"), client: &http.Client{}} - if err := invalidURL.Generate(context.Background(), providertypes.GenerateRequest{ - Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, - }, nil); err == nil || !strings.Contains(err.Error(), "build request") { + if err := invalidURL.Generate(context.Background(), userTextGenerateRequest("hello"), nil); err == nil || !strings.Contains(err.Error(), "build request") { t.Fatalf("expected build request error, got %v", err) } @@ -518,9 +588,7 @@ func TestGenerateVariants(t *testing.T) { return nil, io.ErrClosedPipe })}, } - if err := sendErr.Generate(context.Background(), providertypes.GenerateRequest{ - Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, - }, nil); err == nil || !strings.Contains(err.Error(), "send request") { + if err := sendErr.Generate(context.Background(), userTextGenerateRequest("hello"), nil); err == nil || !strings.Contains(err.Error(), "send request") { t.Fatalf("expected send request error, got %v", err) } }) @@ -538,13 +606,53 @@ func TestGenerateVariants(t *testing.T) { if err != nil { t.Fatalf("New() error = %v", err) } - if err := p.Generate(context.Background(), providertypes.GenerateRequest{ - Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, - }, nil); err == nil || !strings.Contains(err.Error(), "invalid api key") { + if err := p.Generate(context.Background(), userTextGenerateRequest("hello"), nil); err == nil || !strings.Contains(err.Error(), "invalid api key") { t.Fatalf("expected parsed provider error, got %v", err) } }) + t.Run("does not retry without tools on 400", func(t *testing.T) { + t.Parallel() + + var reqCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqCount++ + var payload Request + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if reqCount == 1 { + if len(payload.Tools) == 0 { + t.Fatalf("first request should include tools") + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"message":"schema rejected"}}`)) + return + } + t.Fatalf("unexpected retry request without explicit retry policy") + })) + defer server.Close() + + p, err := New(testCfg(server.URL, "gpt-4.1", "test-key"), server.Client()) + if err != nil { + t.Fatalf("New() error = %v", err) + } + events := make(chan providertypes.StreamEvent, 4) + err = p.Generate(context.Background(), providertypes.GenerateRequest{ + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, + Tools: []providertypes.ToolSpec{{Name: "read_file", Description: "read", Schema: map[string]any{"type": "object"}}}, + }, events) + if err == nil || !strings.Contains(err.Error(), "schema rejected") { + t.Fatalf("Generate() expected original 400 error, got %v", err) + } + if reqCount != 1 { + t.Fatalf("expected one request without tool-stripping retry, got %d", reqCount) + } + if drained := drain(events); len(drained) != 0 { + t.Fatalf("expected no stream events after 400, got %+v", drained) + } + }) + t.Run("success", func(t *testing.T) { t.Parallel() @@ -580,7 +688,7 @@ func TestGenerateVariants(t *testing.T) { events := make(chan providertypes.StreamEvent, 4) err = p.Generate(context.Background(), providertypes.GenerateRequest{ Model: "gpt-5.4", - Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, }, events) if err != nil { t.Fatalf("Generate() error = %v", err) diff --git a/internal/provider/openaicompat/chatcompletions/request.go b/internal/provider/openaicompat/chatcompletions/request.go index 23ed957a..283360f1 100644 --- a/internal/provider/openaicompat/chatcompletions/request.go +++ b/internal/provider/openaicompat/chatcompletions/request.go @@ -38,21 +38,26 @@ func BuildRequest(cfg provider.RuntimeConfig, req providertypes.GenerateRequest) } for _, message := range req.Messages { - payload.Messages = append(payload.Messages, ToOpenAIMessage(message)) + msg, err := ToOpenAIMessage(message) + if err != nil { + return Request{}, err + } + payload.Messages = append(payload.Messages, msg) } if len(req.Tools) > 0 { payload.ToolChoice = "auto" payload.Tools = make([]ToolDefinition, 0, len(req.Tools)) for _, spec := range req.Tools { - payload.Tools = append(payload.Tools, ToolDefinition{ + def := ToolDefinition{ Type: "function", Function: FunctionDefinition{ Name: spec.Name, Description: spec.Description, Parameters: spec.Schema, }, - }) + } + payload.Tools = append(payload.Tools, def) } } @@ -60,13 +65,64 @@ func BuildRequest(cfg provider.RuntimeConfig, req providertypes.GenerateRequest) } // ToOpenAIMessage 将通用 Message 转换为 OpenAI 协议消息格式。 -func ToOpenAIMessage(message providertypes.Message) Message { +func ToOpenAIMessage(message providertypes.Message) (Message, error) { + if err := providertypes.ValidateParts(message.Parts); err != nil { + return Message{}, fmt.Errorf("%sinvalid message parts: %w", shared.ErrorPrefix, err) + } + out := Message{ Role: message.Role, - Content: message.Content, ToolCallID: message.ToolCallID, } + var hasImage bool + for _, part := range message.Parts { + if part.Kind == providertypes.ContentPartImage { + hasImage = true + break + } + } + + if !hasImage { + var textBuilder strings.Builder + for _, part := range message.Parts { + if part.Kind == providertypes.ContentPartText { + textBuilder.WriteString(part.Text) + } + } + if text := textBuilder.String(); text != "" { + out.Content = text + } + } else { + var contentParts []MessageContentPart + for _, part := range message.Parts { + switch part.Kind { + case providertypes.ContentPartText: + contentParts = append(contentParts, MessageContentPart{ + Type: "text", + Text: part.Text, + }) + case providertypes.ContentPartImage: + switch { + case part.Image != nil && part.Image.SourceType == providertypes.ImageSourceRemote: + contentParts = append(contentParts, MessageContentPart{ + Type: "image_url", + ImageURL: &ImageURL{ + URL: part.Image.URL, + }, + }) + case part.Image != nil && part.Image.SourceType == providertypes.ImageSourceSessionAsset: + return Message{}, errors.New("session_asset image is not supported in this phase") + default: + return Message{}, errors.New("unsupported image part payload") + } + } + } + if len(contentParts) > 0 { + out.Content = contentParts + } + } + if len(message.ToolCalls) > 0 { out.ToolCalls = make([]ToolCall, 0, len(message.ToolCalls)) for _, call := range message.ToolCalls { @@ -81,7 +137,7 @@ func ToOpenAIMessage(message providertypes.Message) Message { } } - return out + return out, nil } // ParseError 解析 HTTP 错误响应并包装为 ProviderError。 diff --git a/internal/provider/openaicompat/chatcompletions/types.go b/internal/provider/openaicompat/chatcompletions/types.go index da315767..d2ef1eb6 100644 --- a/internal/provider/openaicompat/chatcompletions/types.go +++ b/internal/provider/openaicompat/chatcompletions/types.go @@ -15,11 +15,23 @@ type Request struct { // Message 表示 OpenAI 协议中的消息格式。 type Message struct { Role string `json:"role"` - Content string `json:"content,omitempty"` + Content any `json:"content,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` } +// MessageContentPart 表示多模态消息的单个部分。 +type MessageContentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *ImageURL `json:"image_url,omitempty"` +} + +// ImageURL 表示图片 URL 对象。 +type ImageURL struct { + URL string `json:"url"` +} + // ToolDefinition 表示工具定义的 OpenAI 格式。 type ToolDefinition struct { Type string `json:"type"` diff --git a/internal/provider/openaicompat/driver_internal_test.go b/internal/provider/openaicompat/driver_internal_test.go index d1fa6127..efc373a7 100644 --- a/internal/provider/openaicompat/driver_internal_test.go +++ b/internal/provider/openaicompat/driver_internal_test.go @@ -121,7 +121,7 @@ func TestFetchModelsAndGenerateExtraBranches(t *testing.T) { t.Fatalf("New() error = %v", err) } err = p.Generate(context.Background(), providertypes.GenerateRequest{ - Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, }, nil) if err == nil || !strings.Contains(err.Error(), `unsupported api_style "custom_style"`) { t.Fatalf("expected unsupported api_style error, got %v", err) diff --git a/internal/provider/openaicompat/openaicompat_test.go b/internal/provider/openaicompat/openaicompat_test.go index 6094dd31..8190037e 100644 --- a/internal/provider/openaicompat/openaicompat_test.go +++ b/internal/provider/openaicompat/openaicompat_test.go @@ -156,10 +156,13 @@ func TestToOpenAIMessage_BasicMessage(t *testing.T) { t.Parallel() msg := providertypes.Message{ - Role: "user", - Content: "hello world", + Role: "user", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello world")}, + } + result, err := chatcompletions.ToOpenAIMessage(msg) + if err != nil { + t.Fatalf("ToOpenAIMessage() basic error = %v", err) } - result := chatcompletions.ToOpenAIMessage(msg) if result.Role != "user" || result.Content != "hello world" { t.Fatalf("unexpected basic message: role=%q content=%q", result.Role, result.Content) @@ -174,10 +177,13 @@ func TestToOpenAIMessage_ToolRoleMessage(t *testing.T) { msg := providertypes.Message{ Role: "tool", - Content: "result data", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("result data")}, ToolCallID: "call_123", } - result := chatcompletions.ToOpenAIMessage(msg) + result, err := chatcompletions.ToOpenAIMessage(msg) + if err != nil { + t.Fatalf("ToOpenAIMessage() tool role error = %v", err) + } if result.Role != "tool" || result.ToolCallID != "call_123" { t.Fatalf("unexpected tool message: role=%q toolCallID=%q", result.Role, result.ToolCallID) @@ -194,7 +200,10 @@ func TestToOpenAIMessage_AssistantWithToolCalls(t *testing.T) { {ID: "call_2", Name: "write_file", Arguments: `{"path":"test.go","content":"..."}`}, }, } - result := chatcompletions.ToOpenAIMessage(msg) + result, err := chatcompletions.ToOpenAIMessage(msg) + if err != nil { + t.Fatalf("ToOpenAIMessage() assistant error = %v", err) + } if len(result.ToolCalls) != 2 { t.Fatalf("expected 2 tool calls, got %d", len(result.ToolCalls)) @@ -215,8 +224,11 @@ func TestToOpenAIMessage_AssistantWithToolCalls(t *testing.T) { func TestToOpenAIMessage_EmptyToolCalls(t *testing.T) { t.Parallel() - msg := providertypes.Message{Role: "user", Content: "test"} - result := chatcompletions.ToOpenAIMessage(msg) + msg := providertypes.Message{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("test")}} + result, err := chatcompletions.ToOpenAIMessage(msg) + if err != nil { + t.Fatalf("ToOpenAIMessage() empty tool calls error = %v", err) + } if len(result.ToolCalls) != 0 { t.Fatalf("expected no tool calls for user message, got %d", len(result.ToolCalls)) } @@ -303,7 +315,7 @@ func TestBuildRequest_FallsBackToConfigModel(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -320,7 +332,7 @@ func TestBuildRequest_RequestModelTakesPrecedence(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{Model: "gpt-4-custom", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{Model: "gpt-4-custom", Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -337,7 +349,7 @@ func TestBuildRequest_NoSystemPrompt(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{SystemPrompt: "", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{SystemPrompt: "", Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -356,7 +368,7 @@ func TestBuildRequest_NoTools(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, Tools: nil}) + payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}}, Tools: nil}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -373,7 +385,7 @@ func TestBuildRequest_EmptyToolsSlice(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, Tools: []providertypes.ToolSpec{}}) + payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}}, Tools: []providertypes.ToolSpec{}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -391,7 +403,7 @@ func TestBuildRequest_MultipleTools(t *testing.T) { } payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{ - Messages: []providertypes.Message{{Role: "user", Content: "use tools"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("use tools")}}}, Tools: []providertypes.ToolSpec{ {Name: "tool_a", Description: "Tool A", Schema: map[string]any{"type": "object"}}, {Name: "tool_b", Description: "Tool B", Schema: map[string]any{"type": "object"}}, @@ -416,7 +428,7 @@ func TestBuildRequest_WhitespaceSystemPromptSkipped(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{SystemPrompt: " ", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{SystemPrompt: " ", Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -953,7 +965,7 @@ data: [DONE] events := make(chan providertypes.StreamEvent, 4) err = p.Generate(context.Background(), providertypes.GenerateRequest{ Model: config.OpenAIDefaultModel, - Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}}, }, events) if err != nil { t.Fatalf("Generate() error = %v", err) @@ -1048,9 +1060,9 @@ func TestProviderGenerateConsumesSSEAndMergesToolCalls(t *testing.T) { err = p.Generate(context.Background(), providertypes.GenerateRequest{ Model: "gpt-5.4", Messages: []providertypes.Message{ - {Role: "user", Content: "please edit the file"}, + {Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("please edit the file")}}, {Role: "assistant", ToolCalls: []providertypes.ToolCall{{ID: "call_1", Name: "filesystem_edit", Arguments: `{"path":"main.go","search_string":"old","replace_string":"new"}`}}}, - {Role: "tool", ToolCallID: "call_1", Content: "tool finished"}, + {Role: "tool", ToolCallID: "call_1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool finished")}}, }, Tools: []providertypes.ToolSpec{{Name: "filesystem_edit", Description: "Edit one matching block in a file", Schema: map[string]any{"type": "object"}}}, }, events) @@ -1161,7 +1173,7 @@ func TestProviderGenerateRejectsUnsupportedAPIStyle(t *testing.T) { err = p.Generate(context.Background(), providertypes.GenerateRequest{ Model: config.OpenAIDefaultModel, - Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}}, }, make(chan providertypes.StreamEvent, 1)) if err == nil { t.Fatal("expected unsupported api_style error") @@ -1182,9 +1194,9 @@ func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{ SystemPrompt: "system prompt", Messages: []providertypes.Message{ - {Role: "user", Content: "hello"}, + {Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, {Role: "assistant", ToolCalls: []providertypes.ToolCall{{ID: "call_1", Name: "filesystem_edit", Arguments: `{"path":"main.go"}`}}}, - {Role: "tool", ToolCallID: "call_1", Content: "tool finished"}, + {Role: "tool", ToolCallID: "call_1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool finished")}}, }, Tools: []providertypes.ToolSpec{{Name: "filesystem_edit", Description: "Edit file content", Schema: map[string]any{"type": "object"}}}, }) @@ -1571,7 +1583,7 @@ func TestProviderGenerateEmitsToolCallStartEvent(t *testing.T) { events := make(chan providertypes.StreamEvent, 8) err = p.Generate(context.Background(), providertypes.GenerateRequest{ - Model: config.OpenAIDefaultModel, Messages: []providertypes.Message{{Role: "user", Content: "edit"}}, + Model: config.OpenAIDefaultModel, Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit")}}}, Tools: []providertypes.ToolSpec{{Name: "filesystem_edit", Description: "edit", Schema: map[string]any{"type": "object"}}}, }, events) if err != nil { @@ -1615,7 +1627,7 @@ func TestProviderGenerateEmitsFullEventStream(t *testing.T) { p.client = server.Client() events := make(chan providertypes.StreamEvent, 16) - err = p.Generate(context.Background(), providertypes.GenerateRequest{Model: config.OpenAIDefaultModel, Messages: []providertypes.Message{{Role: "user", Content: "test"}}}, events) + err = p.Generate(context.Background(), providertypes.GenerateRequest{Model: config.OpenAIDefaultModel, Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("test")}}}}, events) if err != nil { t.Fatalf("Generate() error = %v", err) } diff --git a/internal/provider/streaming/accumulator.go b/internal/provider/streaming/accumulator.go index 8ec35b26..70e0ea3b 100644 --- a/internal/provider/streaming/accumulator.go +++ b/internal/provider/streaming/accumulator.go @@ -40,8 +40,8 @@ func (a *Accumulator) BuildMessage() (providertypes.Message, error) { sort.Ints(ordered) message := providertypes.Message{ - Role: providertypes.RoleAssistant, - Content: a.content.String(), + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(a.content.String())}, } for _, index := range ordered { call := a.toolCalls[index] diff --git a/internal/provider/streaming/accumulator_test.go b/internal/provider/streaming/accumulator_test.go new file mode 100644 index 00000000..e0079be2 --- /dev/null +++ b/internal/provider/streaming/accumulator_test.go @@ -0,0 +1,90 @@ +package streaming + +import ( + "strings" + "testing" + + providertypes "neo-code/internal/provider/types" +) + +func TestAccumulatorNilReceiver(t *testing.T) { + var acc *Accumulator + + if acc.MessageDone() { + t.Fatalf("nil accumulator should not be done") + } + + message, err := acc.BuildMessage() + if err != nil { + t.Fatalf("BuildMessage() error = %v", err) + } + if message.Role != providertypes.RoleAssistant { + t.Fatalf("message role = %q, want %q", message.Role, providertypes.RoleAssistant) + } + if len(message.Parts) != 0 { + t.Fatalf("nil accumulator should not produce parts, got %d", len(message.Parts)) + } + + acc.AccumulateText("ignored") + acc.AccumulateToolCallStart(0, "id", "name") + acc.AccumulateToolCallDelta(0, "id", "{}") + acc.MarkMessageDone() +} + +func TestAccumulatorBuildMessageWithTextAndToolCalls(t *testing.T) { + acc := NewAccumulator() + acc.AccumulateText("hello ") + acc.AccumulateText("world") + + acc.AccumulateToolCallStart(2, "call-2", "bash") + acc.AccumulateToolCallDelta(2, "", "{\"cmd\":\"echo ") + acc.AccumulateToolCallDelta(2, "", "ok\"}") + acc.AccumulateToolCallStart(0, "call-0", "read_file") + acc.AccumulateToolCallDelta(0, "call-0", "{\"path\":\"README.md\"}") + + acc.MarkMessageDone() + if !acc.MessageDone() { + t.Fatalf("expected message done") + } + + message, err := acc.BuildMessage() + if err != nil { + t.Fatalf("BuildMessage() error = %v", err) + } + + if len(message.Parts) != 1 || message.Parts[0].Text != "hello world" { + t.Fatalf("unexpected message parts: %#v", message.Parts) + } + if len(message.ToolCalls) != 2 { + t.Fatalf("tool calls len = %d, want 2", len(message.ToolCalls)) + } + if message.ToolCalls[0].ID != "call-0" || message.ToolCalls[0].Name != "read_file" { + t.Fatalf("unexpected first tool call: %#v", message.ToolCalls[0]) + } + if message.ToolCalls[1].ID != "call-2" || message.ToolCalls[1].Name != "bash" { + t.Fatalf("unexpected second tool call: %#v", message.ToolCalls[1]) + } + if message.ToolCalls[1].Arguments != "{\"cmd\":\"echo ok\"}" { + t.Fatalf("unexpected accumulated arguments: %q", message.ToolCalls[1].Arguments) + } +} + +func TestAccumulatorBuildMessageErrorBranches(t *testing.T) { + t.Run("missing id", func(t *testing.T) { + acc := NewAccumulator() + acc.AccumulateToolCallStart(1, "", "bash") + _, err := acc.BuildMessage() + if err == nil || !strings.Contains(err.Error(), "without id") { + t.Fatalf("expected missing id error, got %v", err) + } + }) + + t.Run("missing name", func(t *testing.T) { + acc := NewAccumulator() + acc.AccumulateToolCallDelta(1, "call-1", "{}") + _, err := acc.BuildMessage() + if err == nil || !strings.Contains(err.Error(), "without name") { + t.Fatalf("expected missing name error, got %v", err) + } + }) +} diff --git a/internal/provider/types/content_part.go b/internal/provider/types/content_part.go new file mode 100644 index 00000000..dd18c127 --- /dev/null +++ b/internal/provider/types/content_part.go @@ -0,0 +1,134 @@ +package types + +import ( + "errors" + "strings" +) + +// ContentPartKind defines the type of a content part. +type ContentPartKind string + +const ( + // ContentPartText represents a text part. + ContentPartText ContentPartKind = "text" + // ContentPartImage represents an image part. + ContentPartImage ContentPartKind = "image" +) + +// ImageSourceType defines the source of an image. +type ImageSourceType string + +const ( + // ImageSourceRemote indicates the image is from a remote URL. + ImageSourceRemote ImageSourceType = "remote" + // ImageSourceSessionAsset indicates the image is stored locally in the session. + ImageSourceSessionAsset ImageSourceType = "session_asset" +) + +// AssetRef references a locally stored asset. +type AssetRef struct { + ID string `json:"id"` + MimeType string `json:"mime_type"` +} + +// ImagePart contains the payload for an image content part. +type ImagePart struct { + SourceType ImageSourceType `json:"source_type"` + URL string `json:"url,omitempty"` + Asset *AssetRef `json:"asset,omitempty"` +} + +// ContentPart represents a single piece of multi-modal content. +type ContentPart struct { + Kind ContentPartKind `json:"kind"` + Text string `json:"text,omitempty"` + Image *ImagePart `json:"image,omitempty"` +} + +// NewTextPart creates a new text content part. +func NewTextPart(text string) ContentPart { + return ContentPart{ + Kind: ContentPartText, + Text: text, + } +} + +// NewRemoteImagePart creates a new image content part from a remote URL. +func NewRemoteImagePart(url string) ContentPart { + return ContentPart{ + Kind: ContentPartImage, + Image: &ImagePart{ + SourceType: ImageSourceRemote, + URL: url, + }, + } +} + +// NewSessionAssetImagePart creates a new image content part from a session asset. +func NewSessionAssetImagePart(id, mimeType string) ContentPart { + return ContentPart{ + Kind: ContentPartImage, + Image: &ImagePart{ + SourceType: ImageSourceSessionAsset, + Asset: &AssetRef{ + ID: id, + MimeType: mimeType, + }, + }, + } +} + +// ValidateParts checks if the given parts are valid. +func ValidateParts(parts []ContentPart) error { + for _, part := range parts { + switch part.Kind { + case ContentPartText: + if part.Image != nil { + return errors.New("text part cannot contain image payload") + } + case ContentPartImage: + image := part.Image + if image == nil { + return errors.New("image part must contain image payload") + } + + switch image.SourceType { + case ImageSourceRemote: + if strings.TrimSpace(image.URL) == "" { + return errors.New("remote image part must contain url") + } + case ImageSourceSessionAsset: + if image.Asset == nil || image.Asset.ID == "" { + return errors.New("session asset image part must contain asset ID") + } + default: + return errors.New("image part contains unsupported source type") + } + default: + return errors.New("unknown content part kind") + } + } + + return nil +} + +// CloneParts creates a deep copy of a slice of ContentPart. +func CloneParts(parts []ContentPart) []ContentPart { + if parts == nil { + return nil + } + res := make([]ContentPart, len(parts)) + for i, p := range parts { + clone := p + if p.Image != nil { + imgClone := *p.Image + if p.Image.Asset != nil { + assetClone := *p.Image.Asset + imgClone.Asset = &assetClone + } + clone.Image = &imgClone + } + res[i] = clone + } + return res +} diff --git a/internal/provider/types/content_part_test.go b/internal/provider/types/content_part_test.go new file mode 100644 index 00000000..0d4e45ea --- /dev/null +++ b/internal/provider/types/content_part_test.go @@ -0,0 +1,125 @@ +package types + +import ( + "testing" +) + +func TestNewTextPart(t *testing.T) { + part := NewTextPart("hello") + if part.Kind != ContentPartText { + t.Errorf("expected kind %s, got %s", ContentPartText, part.Kind) + } + if part.Text != "hello" { + t.Errorf("expected text %s, got %s", "hello", part.Text) + } +} + +func TestNewRemoteImagePart(t *testing.T) { + part := NewRemoteImagePart("https://example.com/image.png") + if part.Kind != ContentPartImage { + t.Errorf("expected kind %s, got %s", ContentPartImage, part.Kind) + } + if part.Image == nil || part.Image.SourceType != ImageSourceRemote || part.Image.URL != "https://example.com/image.png" { + t.Errorf("invalid image part: %+v", part) + } +} + +func TestNewSessionAssetImagePart(t *testing.T) { + part := NewSessionAssetImagePart("asset-1", "image/jpeg") + if part.Kind != ContentPartImage { + t.Errorf("expected kind %s, got %s", ContentPartImage, part.Kind) + } + if part.Image == nil || part.Image.SourceType != ImageSourceSessionAsset || part.Image.Asset == nil || part.Image.Asset.ID != "asset-1" || part.Image.Asset.MimeType != "image/jpeg" { + t.Errorf("invalid session asset image part: %+v", part) + } +} + +func TestValidateParts(t *testing.T) { + tests := []struct { + name string + parts []ContentPart + wantErr bool + }{ + { + name: "valid text", + parts: []ContentPart{NewTextPart("hello")}, + wantErr: false, + }, + { + name: "text with image payload", + parts: []ContentPart{{Kind: ContentPartText, Image: &ImagePart{}}}, + wantErr: true, + }, + { + name: "valid remote image", + parts: []ContentPart{NewRemoteImagePart("http://example.com/img.png")}, + wantErr: false, + }, + { + name: "remote image missing url", + parts: []ContentPart{{Kind: ContentPartImage, Image: &ImagePart{SourceType: ImageSourceRemote}}}, + wantErr: true, + }, + { + name: "remote image blank url", + parts: []ContentPart{{Kind: ContentPartImage, Image: &ImagePart{SourceType: ImageSourceRemote, URL: " "}}}, + wantErr: true, + }, + { + name: "image missing payload", + parts: []ContentPart{{Kind: ContentPartImage}}, + wantErr: true, + }, + { + name: "valid session asset image", + parts: []ContentPart{NewSessionAssetImagePart("123", "image/png")}, + wantErr: false, + }, + { + name: "session asset missing asset ID", + parts: []ContentPart{{Kind: ContentPartImage, Image: &ImagePart{SourceType: ImageSourceSessionAsset, Asset: &AssetRef{}}}}, + wantErr: true, + }, + { + name: "unsupported image source type", + parts: []ContentPart{{Kind: ContentPartImage, Image: &ImagePart{SourceType: ImageSourceType("local_file"), URL: "file://tmp/test.png"}}}, + wantErr: true, + }, + { + name: "unknown kind", + parts: []ContentPart{{Kind: "unknown"}}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateParts(tt.parts) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateParts() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestCloneParts(t *testing.T) { + original := []ContentPart{ + NewTextPart("hello"), + NewSessionAssetImagePart("asset-123", "image/png"), + } + cloned := CloneParts(original) + + if len(cloned) != len(original) { + t.Fatalf("expected length %d, got %d", len(original), len(cloned)) + } + + cloned[0].Text = "world" + if original[0].Text == "world" { + t.Errorf("deep copy failed for text part") + } + + cloned[1].Image.Asset.ID = "asset-456" + if original[1].Image.Asset.ID == "asset-456" { + t.Errorf("deep copy failed for image part asset") + } +} diff --git a/internal/provider/types/message.go b/internal/provider/types/message.go index 0cfa4526..a68f2669 100644 --- a/internal/provider/types/message.go +++ b/internal/provider/types/message.go @@ -1,5 +1,7 @@ package types +import "strings" + // RoleSystem 标识系统消息。 const RoleSystem = "system" @@ -15,13 +17,33 @@ const RoleTool = "tool" // Message 表示对话中的单条消息。 type Message struct { Role string `json:"role"` - Content string `json:"content"` + Parts []ContentPart `json:"parts,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` IsError bool `json:"is_error,omitempty"` ToolMetadata map[string]string `json:"tool_metadata,omitempty"` } +// IsEmpty checks if the message has no content parts and no tool calls. +func (m *Message) IsEmpty() bool { + if len(m.ToolCalls) > 0 { + return false + } + + for _, part := range m.Parts { + if part.Kind != ContentPartText || strings.TrimSpace(part.Text) != "" { + return false + } + } + + return true +} + +// Validate ensures the message is well-formed. +func (m *Message) Validate() error { + return ValidateParts(m.Parts) +} + // ToolCall 表示模型发起的工具调用请求。 type ToolCall struct { ID string `json:"id"` diff --git a/internal/provider/types/message_test.go b/internal/provider/types/message_test.go new file mode 100644 index 00000000..6248f562 --- /dev/null +++ b/internal/provider/types/message_test.go @@ -0,0 +1,84 @@ +package types + +import "testing" + +func TestMessageIsEmpty(t *testing.T) { + tests := []struct { + name string + msg Message + want bool + }{ + { + name: "no parts and no tool calls", + msg: Message{}, + want: true, + }, + { + name: "whitespace-only text part", + msg: Message{ + Parts: []ContentPart{NewTextPart(" \n\t")}, + }, + want: true, + }, + { + name: "non-empty text part", + msg: Message{ + Parts: []ContentPart{NewTextPart("hello")}, + }, + want: false, + }, + { + name: "image part", + msg: Message{ + Parts: []ContentPart{NewRemoteImagePart("https://example.com/image.png")}, + }, + want: false, + }, + { + name: "tool calls present", + msg: Message{ + Parts: []ContentPart{NewTextPart("")}, + ToolCalls: []ToolCall{{ID: "call_1", Name: "read_file"}}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.msg.IsEmpty(); got != tt.want { + t.Fatalf("IsEmpty() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMessageValidate(t *testing.T) { + t.Run("valid message", func(t *testing.T) { + msg := Message{ + Role: "user", + Parts: []ContentPart{ + NewTextPart("hello"), + NewRemoteImagePart("https://example.com/image.png"), + }, + } + if err := msg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + }) + + t.Run("invalid parts", func(t *testing.T) { + msg := Message{ + Role: "user", + Parts: []ContentPart{{ + Kind: ContentPartImage, + Image: &ImagePart{ + SourceType: ImageSourceType("unsupported"), + }, + }}, + } + if err := msg.Validate(); err == nil { + t.Fatalf("expected Validate() to fail for unsupported image source") + } + }) +} diff --git a/internal/provider/types/parts_render_test.go b/internal/provider/types/parts_render_test.go new file mode 100644 index 00000000..700d4b80 --- /dev/null +++ b/internal/provider/types/parts_render_test.go @@ -0,0 +1,16 @@ +package types + +import "strings" + +func renderPartsForTest(parts []ContentPart) string { + var builder strings.Builder + for _, part := range parts { + switch part.Kind { + case ContentPartText: + builder.WriteString(part.Text) + case ContentPartImage: + builder.WriteString("[Image]") + } + } + return builder.String() +} diff --git a/internal/provider/types/types_test.go b/internal/provider/types/types_test.go index 98292736..a4926bd4 100644 --- a/internal/provider/types/types_test.go +++ b/internal/provider/types/types_test.go @@ -166,13 +166,13 @@ func TestMessageStructFields(t *testing.T) { msg := Message{ Role: RoleUser, - Content: "hello", + Parts: []ContentPart{NewTextPart("hello")}, ToolCalls: []ToolCall{{ID: "t1"}}, ToolCallID: "tc_1", IsError: true, ToolMetadata: map[string]string{"path": "main.go"}, } - if msg.Role != RoleUser || msg.Content != "hello" || len(msg.ToolCalls) != 1 || + if msg.Role != RoleUser || renderPartsForTest(msg.Parts) != "hello" || len(msg.ToolCalls) != 1 || msg.ToolCallID != "tc_1" || !msg.IsError || msg.ToolMetadata["path"] != "main.go" { t.Fatalf("message fields not as expected: %+v", msg) } diff --git a/internal/runtime/compact_generator.go b/internal/runtime/compact_generator.go index 570dd276..8a68f675 100644 --- a/internal/runtime/compact_generator.go +++ b/internal/runtime/compact_generator.go @@ -87,8 +87,8 @@ func (g *compactSummaryGenerator) Generate( Model: g.model, SystemPrompt: prompt.SystemPrompt, Messages: []providertypes.Message{{ - Role: providertypes.RoleUser, - Content: prompt.UserPrompt, + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(prompt.UserPrompt)}, }}, }, streaming.Hooks{}) if outcome.err != nil { @@ -100,7 +100,21 @@ func (g *compactSummaryGenerator) Generate( return contextcompact.SummaryOutput{}, errors.New("runtime: compact summary response must not contain tool calls") } - return parseCompactSummaryOutput(message.Content) + return parseCompactSummaryOutput(renderCompactSummaryResponseParts(message.Parts)) +} + +// renderCompactSummaryResponseParts 提取 compact 生成器响应文本,非文本分片仅保留占位以避免二进制泄露。 +func renderCompactSummaryResponseParts(parts []providertypes.ContentPart) string { + var builder strings.Builder + for _, part := range parts { + switch part.Kind { + case providertypes.ContentPartText: + builder.WriteString(part.Text) + case providertypes.ContentPartImage: + builder.WriteString("[Image]") + } + } + return builder.String() } // parseCompactSummaryOutput 解析 compact 生成器返回的 JSON 响应,容忍数组字段被返回为字符串。 diff --git a/internal/runtime/compact_generator_test.go b/internal/runtime/compact_generator_test.go index 2472dccf..ebfd65da 100644 --- a/internal/runtime/compact_generator_test.go +++ b/internal/runtime/compact_generator_test.go @@ -43,7 +43,7 @@ func TestCompactSummaryGeneratorBuildsProviderRequestWithoutTools(t *testing.T) OpenItems: []string{"Update runtime tests"}, }, ArchivedMessages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "legacy request"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("legacy request")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ @@ -52,7 +52,7 @@ func TestCompactSummaryGeneratorBuildsProviderRequestWithoutTools(t *testing.T) }, }, RetainedMessages: []providertypes.Message{ - {Role: providertypes.RoleAssistant, Content: "recent answer"}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent answer")}}, }, ArchivedMessageCount: 2, Config: manager.Get().Context.Compact, @@ -92,23 +92,24 @@ func TestCompactSummaryGeneratorBuildsProviderRequestWithoutTools(t *testing.T) if len(req.Messages) != 1 || req.Messages[0].Role != providertypes.RoleUser { t.Fatalf("expected a single user prompt, got %+v", req.Messages) } - if !strings.Contains(req.Messages[0].Content, "") { - t.Fatalf("expected archived material boundary, got %q", req.Messages[0].Content) + promptText := renderPartsForTest(req.Messages[0].Parts) + if !strings.Contains(promptText, "") { + t.Fatalf("expected archived material boundary, got %q", promptText) } - if !strings.Contains(req.Messages[0].Content, "") { - t.Fatalf("expected task state boundary, got %q", req.Messages[0].Content) + if !strings.Contains(promptText, "") { + t.Fatalf("expected task state boundary, got %q", promptText) } - if strings.Contains(req.Messages[0].Content, "\"role\": \"user\"") { - t.Fatalf("expected transcript-style compact prompt instead of pretty JSON, got %q", req.Messages[0].Content) + if strings.Contains(promptText, "\"role\": \"user\"") { + t.Fatalf("expected transcript-style compact prompt instead of pretty JSON, got %q", promptText) } - if !strings.Contains(req.Messages[0].Content, "[message 0] role=user") { - t.Fatalf("expected transcript-style user message header, got %q", req.Messages[0].Content) + if !strings.Contains(promptText, "[message 0] role=user") { + t.Fatalf("expected transcript-style user message header, got %q", promptText) } - if !strings.Contains(req.Messages[0].Content, "tool_call id=call-1 name=filesystem_read_file") { - t.Fatalf("expected tool call metadata in compact prompt, got %q", req.Messages[0].Content) + if !strings.Contains(promptText, "tool_call id=call-1 name=filesystem_read_file") { + t.Fatalf("expected tool call metadata in compact prompt, got %q", promptText) } - if !strings.Contains(req.Messages[0].Content, `"goal": "Finish task state refactor"`) { - t.Fatalf("expected current task state JSON in compact prompt, got %q", req.Messages[0].Content) + if !strings.Contains(promptText, `"goal": "Finish task state refactor"`) { + t.Fatalf("expected current task state JSON in compact prompt, got %q", promptText) } } @@ -134,7 +135,7 @@ func TestCompactSummaryGeneratorRejectsToolCalls(t *testing.T) { _, err = generator.Generate(context.Background(), contextcompact.SummaryInput{ Mode: contextcompact.ModeManual, ArchivedMessages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "legacy request"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("legacy request")}}, }, Config: manager.Get().Context.Compact, }) diff --git a/internal/runtime/parts_render_test.go b/internal/runtime/parts_render_test.go new file mode 100644 index 00000000..4fdaaa22 --- /dev/null +++ b/internal/runtime/parts_render_test.go @@ -0,0 +1,20 @@ +package runtime + +import ( + "strings" + + providertypes "neo-code/internal/provider/types" +) + +func renderPartsForTest(parts []providertypes.ContentPart) string { + var builder strings.Builder + for _, part := range parts { + switch part.Kind { + case providertypes.ContentPartText: + builder.WriteString(part.Text) + case providertypes.ContentPartImage: + builder.WriteString("[Image]") + } + } + return builder.String() +} diff --git a/internal/runtime/permission_test.go b/internal/runtime/permission_test.go index beef78cb..f23d8e22 100644 --- a/internal/runtime/permission_test.go +++ b/internal/runtime/permission_test.go @@ -374,7 +374,7 @@ func TestServiceRunPermissionRejectFlow(t *testing.T) { FinishReason: "tool_calls", }, { - Message: providertypes.Message{Role: "assistant", Content: "done"}, + Message: providertypes.Message{Role: "assistant", Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}}, FinishReason: "stop", }, }, @@ -383,7 +383,7 @@ func TestServiceRunPermissionRejectFlow(t *testing.T) { service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, nil) runErrCh := make(chan error, 1) go func() { - runErrCh <- service.Run(context.Background(), UserInput{RunID: "run-permission-reject", Content: "fetch private"}) + runErrCh <- service.Run(context.Background(), UserInput{RunID: "run-permission-reject", Parts: []providertypes.ContentPart{providertypes.NewTextPart("fetch private")}}) }() var requestPayload PermissionRequestPayload @@ -492,7 +492,7 @@ func TestServiceRunMCPPermissionAllowFlow(t *testing.T) { FinishReason: "tool_calls", }, { - Message: providertypes.Message{Role: "assistant", Content: "done"}, + Message: providertypes.Message{Role: "assistant", Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}}, FinishReason: "stop", }, }, @@ -501,7 +501,7 @@ func TestServiceRunMCPPermissionAllowFlow(t *testing.T) { service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, nil) runErrCh := make(chan error, 1) go func() { - runErrCh <- service.Run(context.Background(), UserInput{RunID: "run-mcp-permission-allow", Content: "create issue"}) + runErrCh <- service.Run(context.Background(), UserInput{RunID: "run-mcp-permission-allow", Parts: []providertypes.ContentPart{providertypes.NewTextPart("create issue")}}) }() var requestPayload PermissionRequestPayload @@ -627,7 +627,7 @@ func TestServiceRunMCPPermissionRejectFlow(t *testing.T) { FinishReason: "tool_calls", }, { - Message: providertypes.Message{Role: "assistant", Content: "done"}, + Message: providertypes.Message{Role: "assistant", Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}}, FinishReason: "stop", }, }, @@ -636,7 +636,7 @@ func TestServiceRunMCPPermissionRejectFlow(t *testing.T) { service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, nil) runErrCh := make(chan error, 1) go func() { - runErrCh <- service.Run(context.Background(), UserInput{RunID: "run-mcp-permission-reject", Content: "create issue"}) + runErrCh <- service.Run(context.Background(), UserInput{RunID: "run-mcp-permission-reject", Parts: []providertypes.ContentPart{providertypes.NewTextPart("create issue")}}) }() var requestPayload PermissionRequestPayload @@ -758,14 +758,14 @@ func TestServiceRunMCPPermissionHardDenyFlow(t *testing.T) { FinishReason: "tool_calls", }, { - Message: providertypes.Message{Role: "assistant", Content: "done"}, + Message: providertypes.Message{Role: "assistant", Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}}, FinishReason: "stop", }, }, } service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, nil) - if err := service.Run(context.Background(), UserInput{RunID: "run-mcp-permission-deny", Content: "create issue"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-mcp-permission-deny", Parts: []providertypes.ContentPart{providertypes.NewTextPart("create issue")}}); err != nil { t.Fatalf("Run() error = %v", err) } if mcpClient.callCount != 0 { diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 442a76d1..174eba9e 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -71,8 +71,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { }() ctx = runCtx - if strings.TrimSpace(input.Content) == "" { - err = errors.New("runtime: input content is empty") + if err = validateUserInputParts(input.Parts); err != nil { return err } @@ -83,7 +82,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { releaseSessionLock() }() - session, err := s.loadOrCreateSession(ctx, input.SessionID, input.Content, initialCfg.Workdir, input.Workdir) + sessionTitle := sessionTitleFromParts(input.Parts) + session, err := s.loadOrCreateSession(ctx, input.SessionID, sessionTitle, initialCfg.Workdir, input.Workdir) if err != nil { return s.handleRunError(ctx, input.RunID, input.SessionID, err) } @@ -94,7 +94,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { state := newRunState(input.RunID, session) statePtr = &state - if err := s.appendUserMessageAndSave(ctx, &state, input.Content); err != nil { + if err := s.appendUserMessageAndSave(ctx, &state, input.Parts); err != nil { return s.handleRunError(ctx, state.runID, state.session.ID, err) } @@ -439,6 +439,47 @@ func degradeKeepRecentMessages(base int, attempt int) int { return base } +// validateUserInputParts 校验输入 parts 的结构合法性和语义有效性,避免空白文本触发无效运行。 +func validateUserInputParts(parts []providertypes.ContentPart) error { + if len(parts) == 0 { + return errors.New("runtime: input parts is empty") + } + if err := providertypes.ValidateParts(parts); err != nil { + return fmt.Errorf("runtime: invalid input parts: %w", err) + } + if !hasUserInputParts(parts) { + return errors.New("runtime: input content is empty") + } + return nil +} + +// hasUserInputParts 判断用户输入是否包含可执行语义,图片输入也应被视为有效请求。 +func hasUserInputParts(parts []providertypes.ContentPart) bool { + for _, part := range parts { + switch part.Kind { + case providertypes.ContentPartText: + if strings.TrimSpace(part.Text) != "" { + return true + } + case providertypes.ContentPartImage: + if part.Image != nil { + return true + } + } + } + return false +} + +// sessionTitleFromParts extracts a sensible title from the input parts. +func sessionTitleFromParts(parts []providertypes.ContentPart) string { + for _, part := range parts { + if part.Kind == providertypes.ContentPartText && strings.TrimSpace(part.Text) != "" { + return strings.TrimSpace(part.Text) + } + } + return "Image Message" +} + // fallbackAutoCompactThreshold 返回自动推导失败时仍可继续使用的保底阈值。 func fallbackAutoCompactThreshold(cfg config.Config) int { if cfg.Context.AutoCompact.FallbackInputTokenThreshold > 0 { diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index ce5546a4..ae06687d 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -43,7 +43,7 @@ type Runtime interface { type UserInput struct { SessionID string RunID string - Content string + Parts []providertypes.ContentPart Workdir string } diff --git a/internal/runtime/runtime_gap_coverage_test.go b/internal/runtime/runtime_gap_coverage_test.go index cd6d6685..548c37a2 100644 --- a/internal/runtime/runtime_gap_coverage_test.go +++ b/internal/runtime/runtime_gap_coverage_test.go @@ -45,7 +45,7 @@ func TestRunCompactForSessionPolicyBranches(t *testing.T) { t.Parallel() session := newRuntimeSession("session-compact-policy") - session.Messages = []providertypes.Message{{Role: providertypes.RoleUser, Content: "before"}} + session.Messages = []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("before")}}} service := &Service{ events: make(chan RuntimeEvent, 32), @@ -77,7 +77,7 @@ func TestRunCompactForSessionSaveErrorPolicyBranches(t *testing.T) { baseStore := newMemoryStore() session := newRuntimeSession("session-compact-save-error") - session.Messages = []providertypes.Message{{Role: providertypes.RoleUser, Content: "before"}} + session.Messages = []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("before")}}} session.TaskState.Goal = "before-goal" session.TokenInputTotal = 11 session.TokenOutputTotal = 22 @@ -91,7 +91,7 @@ func TestRunCompactForSessionSaveErrorPolicyBranches(t *testing.T) { events: make(chan RuntimeEvent, 16), compactRunner: &stubCompactRunner{result: contextcompact.Result{ Applied: true, - Messages: []providertypes.Message{{Role: providertypes.RoleAssistant, Content: "after"}}, + Messages: []providertypes.Message{{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("after")}}}, TaskState: agentsession.TaskState{ Goal: "after-goal", }, @@ -102,7 +102,7 @@ func TestRunCompactForSessionSaveErrorPolicyBranches(t *testing.T) { if err == nil { t.Fatalf("expected strict mode save error") } - if strictSession.Messages[0].Content != "before" { + if renderPartsForTest(strictSession.Messages[0].Parts) != "before" { t.Fatalf("expected strict mode to rollback messages, got %+v", strictSession.Messages) } if strictSession.TaskState.Goal != "before-goal" { @@ -123,7 +123,7 @@ func TestRunCompactForSessionSaveErrorPolicyBranches(t *testing.T) { if bestEffortResult.Applied { t.Fatalf("expected empty compact result on best effort save failure") } - if bestEffortSession.Messages[0].Content != "before" { + if renderPartsForTest(bestEffortSession.Messages[0].Parts) != "before" { t.Fatalf("expected best effort rollback messages, got %+v", bestEffortSession.Messages) } if bestEffortSession.TaskState.Goal != "before-goal" { diff --git a/internal/runtime/runtime_internal_helpers_test.go b/internal/runtime/runtime_internal_helpers_test.go index 46ac92f4..2e136e67 100644 --- a/internal/runtime/runtime_internal_helpers_test.go +++ b/internal/runtime/runtime_internal_helpers_test.go @@ -54,6 +54,17 @@ func (s *stubMemoExtractor) Schedule(_ string, messages []providertypes.Message) } } +func TestValidateUserInputPartsAcceptsPureImage(t *testing.T) { + t.Parallel() + + parts := []providertypes.ContentPart{ + providertypes.NewRemoteImagePart("https://example.com/image.png"), + } + if err := validateUserInputParts(parts); err != nil { + t.Fatalf("validateUserInputParts() error = %v", err) + } +} + func TestRunStateNilReceiverNoops(t *testing.T) { t.Parallel() @@ -200,8 +211,8 @@ func TestAppendToolMessageAndSavePreservesMetadataOnlySuccessResult(t *testing.T } msg := state.session.Messages[0] - if msg.Content != "" { - t.Fatalf("expected metadata-only success result to keep empty content, got %q", msg.Content) + if renderPartsForTest(msg.Parts) != "" { + t.Fatalf("expected metadata-only success result to keep empty content, got %q", renderPartsForTest(msg.Parts)) } if msg.ToolMetadata["tool_name"] != "filesystem_read_file" || msg.ToolMetadata["path"] != "README.md" { t.Fatalf("expected metadata-only success result to keep sanitized metadata, got %+v", msg.ToolMetadata) @@ -228,8 +239,8 @@ func TestAppendToolMessageAndSaveNormalizesSemanticallyEmptySuccessResult(t *tes } msg := state.session.Messages[0] - if msg.Content != "ok" { - t.Fatalf("expected empty success result to be normalized to ok, got %q", msg.Content) + if renderPartsForTest(msg.Parts) != "ok" { + t.Fatalf("expected empty success result to be normalized to ok, got %q", renderPartsForTest(msg.Parts)) } if msg.ToolMetadata["tool_name"] != "filesystem_read_file" { t.Fatalf("expected tool_name metadata to be preserved after normalization, got %+v", msg.ToolMetadata) @@ -259,8 +270,8 @@ func TestAppendToolMessageAndSaveNormalizesToolNameOnlyMetadataSuccessResult(t * } msg := state.session.Messages[0] - if msg.Content != "ok" { - t.Fatalf("expected tool_name-only metadata success to normalize content to ok, got %q", msg.Content) + if renderPartsForTest(msg.Parts) != "ok" { + t.Fatalf("expected tool_name-only metadata success to normalize content to ok, got %q", renderPartsForTest(msg.Parts)) } if len(msg.ToolMetadata) != 1 || msg.ToolMetadata["tool_name"] != "filesystem_read_file" { t.Fatalf("expected only tool_name metadata to remain, got %+v", msg.ToolMetadata) @@ -391,8 +402,8 @@ func TestExecuteAssistantToolCallsFillsErrorContent(t *testing.T) { if len(state.session.Messages) != 1 { t.Fatalf("expected one tool message, got %d", len(state.session.Messages)) } - if state.session.Messages[0].Content != toolErr.Error() { - t.Fatalf("expected tool error content fallback, got %q", state.session.Messages[0].Content) + if renderPartsForTest(state.session.Messages[0].Parts) != toolErr.Error() { + t.Fatalf("expected tool error content fallback, got %q", renderPartsForTest(state.session.Messages[0].Parts)) } } @@ -458,7 +469,7 @@ func TestSetMemoExtractorAndRunTriggersExtraction(t *testing.T) { extractor := &stubMemoExtractor{doneCh: make(chan struct{}, 1)} service.SetMemoExtractor(extractor) - if err := service.Run(context.Background(), UserInput{RunID: "run-memo-extract", Content: "hello"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-memo-extract", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}); err != nil { t.Fatalf("Run() error = %v", err) } diff --git a/internal/runtime/runtime_progress_test.go b/internal/runtime/runtime_progress_test.go index 89ec5189..9b6df4c3 100644 --- a/internal/runtime/runtime_progress_test.go +++ b/internal/runtime/runtime_progress_test.go @@ -67,8 +67,8 @@ func TestProgressStreakStopsRun(t *testing.T) { ) input := UserInput{ - RunID: "run-progress", - Content: "trigger error loop", + RunID: "run-progress", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("trigger error loop")}, } err := service.Run(context.Background(), input) @@ -147,8 +147,8 @@ func TestProgressEvidenceResetsNoProgressStreak(t *testing.T) { ) err := service.Run(context.Background(), UserInput{ - RunID: "run-progress-reset", - Content: "trigger mixed progress loop", + RunID: "run-progress-reset", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("trigger mixed progress loop")}, }) if err != nil { t.Fatalf("expected run to finish successfully, got %v", err) @@ -216,8 +216,8 @@ func TestRepeatCycleStreakStopsRunAndInjectsReminder(t *testing.T) { ) err := service.Run(context.Background(), UserInput{ - RunID: "run-repeat-streak", - Content: "trigger repeat loop", + RunID: "run-repeat-streak", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("trigger repeat loop")}, }) if err == nil { t.Fatal("expected repeat cycle limit error, got nil") @@ -288,8 +288,8 @@ func TestRepeatCycleStreakCountsFailedToolCalls(t *testing.T) { ) err := service.Run(context.Background(), UserInput{ - RunID: "run-repeat-fail-streak", - Content: "trigger repeat fail loop", + RunID: "run-repeat-fail-streak", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("trigger repeat fail loop")}, }) if !errors.Is(err, ErrRepeatCycleLimit) { t.Fatalf("expected ErrRepeatCycleLimit, got %v", err) diff --git a/internal/runtime/runtime_remaining_branches_test.go b/internal/runtime/runtime_remaining_branches_test.go index 96c53dc3..5e1fd701 100644 --- a/internal/runtime/runtime_remaining_branches_test.go +++ b/internal/runtime/runtime_remaining_branches_test.go @@ -448,7 +448,7 @@ func TestRunAndProviderRetryRemainingBranches(t *testing.T) { store := &postSaveHookStore{base: base, saveHook: cancel} service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, &stubContextBuilder{}) - err := service.Run(ctx, UserInput{RunID: "run-cancel-before-snapshot", SessionID: existing.ID, Content: "hello"}) + err := service.Run(ctx, UserInput{RunID: "run-cancel-before-snapshot", SessionID: existing.ID, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}) if !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } @@ -465,7 +465,7 @@ func TestRunAndProviderRetryRemainingBranches(t *testing.T) { service := NewWithFactory(manager, &stubToolManager{}, store, &scriptedProviderFactory{provider: &scriptedProvider{streams: [][]providertypes.StreamEvent{{providertypes.NewTextDeltaStreamEvent("answer")}}}}, &stubContextBuilder{}) - err := service.Run(context.Background(), UserInput{RunID: "run-assistant-save-fail", SessionID: existing.ID, Content: "hello"}) + err := service.Run(context.Background(), UserInput{RunID: "run-assistant-save-fail", SessionID: existing.ID, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}) if err == nil || !containsError(err, "assistant save failed") { t.Fatalf("expected assistant save error, got %v", err) } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 623c2de5..f5677dc6 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -231,9 +231,9 @@ func (p *scriptedProvider) Generate(ctx context.Context, req providertypes.Gener return ctx.Err() } } - if response.Message.Content != "" { + if renderPartsForTest(response.Message.Parts) != "" { select { - case events <- providertypes.NewTextDeltaStreamEvent(response.Message.Content): + case events <- providertypes.NewTextDeltaStreamEvent(renderPartsForTest(response.Message.Parts)): case <-ctx.Done(): return ctx.Err() } @@ -444,7 +444,7 @@ func TestServiceRun(t *testing.T) { }{ { name: "normal dialogue exits after final assistant reply", - input: UserInput{RunID: "run-normal", Content: "hello"}, + input: UserInput{RunID: "run-normal", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, providerStreams: [][]providertypes.StreamEvent{ { providertypes.NewTextDeltaStreamEvent("plain "), @@ -456,7 +456,7 @@ func TestServiceRun(t *testing.T) { return agentcontext.BuildResult{ SystemPrompt: "custom system prompt", Messages: []providertypes.Message{ - {Role: "user", Content: "trimmed history"}, + {Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("trimmed history")}}, }, }, nil }, @@ -476,14 +476,14 @@ func TestServiceRun(t *testing.T) { if scripted.requests[0].SystemPrompt != "custom system prompt" { t.Fatalf("expected system prompt from context builder, got %q", scripted.requests[0].SystemPrompt) } - if len(scripted.requests[0].Messages) != 1 || scripted.requests[0].Messages[0].Content != "trimmed history" { + if len(scripted.requests[0].Messages) != 1 || renderPartsForTest(scripted.requests[0].Messages[0].Parts) != "trimmed history" { t.Fatalf("expected messages from context builder, got %+v", scripted.requests[0].Messages) } }, }, { name: "tool call triggers execute and follow-up provider round", - input: UserInput{RunID: "run-tool", Content: "edit file"}, + input: UserInput{RunID: "run-tool", Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit file")}}, // 第一轮:工具调用事件流(tool_call_start + tool_call_delta) // 第二轮:普通文本回复 providerStreams: [][]providertypes.StreamEvent{ @@ -530,10 +530,10 @@ func TestServiceRun(t *testing.T) { for _, message := range second.Messages { if message.Role == "tool" && message.ToolCallID == "call-1" && - strings.Contains(message.Content, "tool result") && - strings.Contains(message.Content, "tool: filesystem_edit") && - strings.Contains(message.Content, "status: ok") && - strings.Contains(message.Content, "content:\ntool output") { + strings.Contains(renderPartsForTest(message.Parts), "tool result") && + strings.Contains(renderPartsForTest(message.Parts), "tool: filesystem_edit") && + strings.Contains(renderPartsForTest(message.Parts), "status: ok") && + strings.Contains(renderPartsForTest(message.Parts), "content:\ntool output") { foundToolResult = true break } @@ -543,7 +543,7 @@ func TestServiceRun(t *testing.T) { } session := onlySession(t, store) - if session.Messages[2].Role != providertypes.RoleTool || session.Messages[2].Content != "tool output" { + if session.Messages[2].Role != providertypes.RoleTool || renderPartsForTest(session.Messages[2].Parts) != "tool output" { t.Fatalf("expected persisted tool message to keep raw content, got %+v", session.Messages[2]) } if session.Messages[2].ToolMetadata["tool_name"] != "filesystem_edit" { @@ -553,7 +553,7 @@ func TestServiceRun(t *testing.T) { }, { name: "metadata-only tool result is projected on follow-up provider round", - input: UserInput{RunID: "run-tool-metadata-only", Content: "inspect file"}, + input: UserInput{RunID: "run-tool-metadata-only", Parts: []providertypes.ContentPart{providertypes.NewTextPart("inspect file")}}, providerStreams: [][]providertypes.StreamEvent{ { providertypes.NewToolCallStartStreamEvent(0, "call-1", "filesystem_read_file"), @@ -597,12 +597,12 @@ func TestServiceRun(t *testing.T) { for _, message := range second.Messages { if message.Role == providertypes.RoleTool && message.ToolCallID == "call-1" && - strings.Contains(message.Content, "tool result") && - strings.Contains(message.Content, "tool: filesystem_read_file") && - strings.Contains(message.Content, "meta.path: README.md") { + strings.Contains(renderPartsForTest(message.Parts), "tool result") && + strings.Contains(renderPartsForTest(message.Parts), "tool: filesystem_read_file") && + strings.Contains(renderPartsForTest(message.Parts), "meta.path: README.md") { foundToolResult = true - if strings.Contains(message.Content, "content:\n") { - t.Fatalf("expected metadata-only projection to omit content section, got %q", message.Content) + if strings.Contains(renderPartsForTest(message.Parts), "content:\n") { + t.Fatalf("expected metadata-only projection to omit content section, got %q", renderPartsForTest(message.Parts)) } break } @@ -612,7 +612,7 @@ func TestServiceRun(t *testing.T) { } session := onlySession(t, store) - if session.Messages[2].Role != providertypes.RoleTool || session.Messages[2].Content != "" { + if session.Messages[2].Role != providertypes.RoleTool || renderPartsForTest(session.Messages[2].Parts) != "" { t.Fatalf("expected persisted tool message to keep empty raw content, got %+v", session.Messages[2]) } if session.Messages[2].ToolMetadata["tool_name"] != "filesystem_read_file" || @@ -700,7 +700,7 @@ func TestServiceRunSchedulesMemoExtractionAfterFinalReply(t *testing.T) { memoExtractor := &stubScheduledMemoExtractor{} service.SetMemoExtractor(memoExtractor) - if err := service.Run(context.Background(), UserInput{RunID: "run-memo-schedule", Content: "hello"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-memo-schedule", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}); err != nil { t.Fatalf("Run() error = %v", err) } if len(memoExtractor.calls) != 1 { @@ -737,7 +737,7 @@ func TestServiceRunSkipsAutoMemoExtractionAfterRememberTool(t *testing.T) { memoExtractor := &stubScheduledMemoExtractor{} service.SetMemoExtractor(memoExtractor) - if err := service.Run(context.Background(), UserInput{RunID: "run-remember-skip", Content: "remember this"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-remember-skip", Parts: []providertypes.ContentPart{providertypes.NewTextPart("remember this")}}); err != nil { t.Fatalf("Run() error = %v", err) } if len(memoExtractor.calls) != 0 { @@ -778,7 +778,7 @@ func TestServiceRunTrustsCallNameForRememberDetection(t *testing.T) { memoExtractor := &stubScheduledMemoExtractor{} service.SetMemoExtractor(memoExtractor) - if err := service.Run(context.Background(), UserInput{RunID: "run-forged-remember", Content: "edit file"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-forged-remember", Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit file")}}); err != nil { t.Fatalf("Run() error = %v", err) } if len(memoExtractor.calls) != 1 { @@ -812,7 +812,7 @@ func TestServiceRunSchedulesMemoExtractionOnlyAfterFinalCompletion(t *testing.T) memoExtractor := &stubScheduledMemoExtractor{} service.SetMemoExtractor(memoExtractor) - if err := service.Run(context.Background(), UserInput{RunID: "run-final-only", Content: "edit file"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-final-only", Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit file")}}); err != nil { t.Fatalf("Run() error = %v", err) } if len(memoExtractor.calls) != 1 { @@ -844,7 +844,7 @@ func TestServiceRunMergesLateToolCallMetadata(t *testing.T) { } service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) - if err := service.Run(context.Background(), UserInput{RunID: "run-late-tool-metadata", Content: "edit"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-late-tool-metadata", Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit")}}); err != nil { t.Fatalf("Run() error = %v", err) } @@ -895,7 +895,7 @@ func TestServiceRunRejectsToolCallWithoutID(t *testing.T) { } service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) - err := service.Run(context.Background(), UserInput{RunID: "run-missing-tool-id", Content: "edit"}) + err := service.Run(context.Background(), UserInput{RunID: "run-missing-tool-id", Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit")}}) if err == nil || !containsError(err, "without id") { t.Fatalf("expected missing tool id error, got %v", err) } @@ -921,7 +921,7 @@ func TestServiceRunRejectsMalformedProviderStreamEvent(t *testing.T) { } service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) - err := service.Run(context.Background(), UserInput{RunID: "run-malformed-stream-event", Content: "hello"}) + err := service.Run(context.Background(), UserInput{RunID: "run-malformed-stream-event", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}) if err == nil || !containsError(err, "text_delta event payload is nil") { t.Fatalf("expected malformed stream event error, got %v", err) } @@ -947,7 +947,7 @@ func TestServiceRunRejectsProviderCompletionWithoutMessageDone(t *testing.T) { } service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) - err := service.Run(context.Background(), UserInput{RunID: "run-missing-message-done", Content: "hello"}) + err := service.Run(context.Background(), UserInput{RunID: "run-missing-message-done", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}) if err == nil || !containsError(err, "without message_done") { t.Fatalf("expected missing message_done error, got %v", err) } @@ -981,7 +981,7 @@ func TestServiceRunMalformedProviderStreamEventDoesNotDeadlock(t *testing.T) { service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) errCh := make(chan error, 1) go func() { - errCh <- service.Run(context.Background(), UserInput{RunID: "run-malformed-stream-no-deadlock", Content: "hello"}) + errCh <- service.Run(context.Background(), UserInput{RunID: "run-malformed-stream-no-deadlock", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}) }() select { @@ -1033,7 +1033,7 @@ func TestServiceRunDelegatesToContextBuilder(t *testing.T) { return agentcontext.BuildResult{ SystemPrompt: "delegated prompt", Messages: []providertypes.Message{ - {Role: "user", Content: "delegated message"}, + {Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("delegated message")}}, }, }, nil }, @@ -1046,7 +1046,7 @@ func TestServiceRunDelegatesToContextBuilder(t *testing.T) { } service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) - input := UserInput{SessionID: session.ID, RunID: "run-context-builder", Content: "hello"} + input := UserInput{SessionID: session.ID, RunID: "run-context-builder", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}} if err := service.Run(context.Background(), input); err != nil { t.Fatalf("Run() error = %v", err) } @@ -1072,7 +1072,7 @@ func TestServiceRunDelegatesToContextBuilder(t *testing.T) { if builder.lastInput.TaskState.Goal != "Finish task state rollout" { t.Fatalf("expected session task state to be forwarded to builder, got %+v", builder.lastInput.TaskState) } - if len(builder.lastInput.Messages) != 1 || builder.lastInput.Messages[0].Content != "hello" { + if len(builder.lastInput.Messages) != 1 || renderPartsForTest(builder.lastInput.Messages[0].Parts) != "hello" { t.Fatalf("expected persisted session messages to be forwarded, got %+v", builder.lastInput.Messages) } if len(scripted.requests) != 1 { @@ -1081,7 +1081,7 @@ func TestServiceRunDelegatesToContextBuilder(t *testing.T) { if scripted.requests[0].SystemPrompt != "delegated prompt" { t.Fatalf("expected delegated prompt, got %q", scripted.requests[0].SystemPrompt) } - if len(scripted.requests[0].Messages) != 1 || scripted.requests[0].Messages[0].Content != "delegated message" { + if len(scripted.requests[0].Messages) != 1 || renderPartsForTest(scripted.requests[0].Messages[0].Parts) != "delegated message" { t.Fatalf("expected delegated messages, got %+v", scripted.requests[0].Messages) } } @@ -1112,13 +1112,13 @@ func TestServiceRunCanDisableMicroCompactViaConfig(t *testing.T) { scripted := &scriptedProvider{ responses: []scriptedResponse{{ - Message: providertypes.Message{Role: providertypes.RoleAssistant, Content: "done"}, + Message: providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}}, FinishReason: "stop", }}, } service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) - if err := service.Run(context.Background(), UserInput{RunID: "run-disable-micro-compact", Content: "hello"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-disable-micro-compact", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}); err != nil { t.Fatalf("Run() error = %v", err) } @@ -1142,7 +1142,7 @@ func TestServiceRunPersistsSessionProviderAndModel(t *testing.T) { } service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) - if err := service.Run(context.Background(), UserInput{RunID: "run-session-provider-model", Content: "hello"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-session-provider-model", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}); err != nil { t.Fatalf("Run() error = %v", err) } @@ -1169,34 +1169,34 @@ func TestServiceRunDefaultBuilderUsesToolManagerMicroCompactPolicies(t *testing. session := agentsession.New("preserve history") session.ID = "session-preserve-history" session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "preserve_tool", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "preserved result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("preserved result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, } store.sessions[session.ID] = cloneSession(session) scripted := &scriptedProvider{ responses: []scriptedResponse{{ - Message: providertypes.Message{Role: providertypes.RoleAssistant, Content: "done"}, + Message: providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}}, FinishReason: "stop", }}, } @@ -1205,7 +1205,7 @@ func TestServiceRunDefaultBuilderUsesToolManagerMicroCompactPolicies(t *testing. if err := service.Run(context.Background(), UserInput{ SessionID: session.ID, RunID: "run-preserve-history-policy", - Content: "latest explicit instruction", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}, }); err != nil { t.Fatalf("Run() error = %v", err) } @@ -1213,7 +1213,7 @@ func TestServiceRunDefaultBuilderUsesToolManagerMicroCompactPolicies(t *testing. if len(scripted.requests) != 1 { t.Fatalf("expected 1 provider request, got %d", len(scripted.requests)) } - if got := scripted.requests[0].Messages[2].Content; got != "preserved result" { + if got := renderPartsForTest(scripted.requests[0].Messages[2].Parts); got != "preserved result" { t.Fatalf("expected preserved tool result to remain visible, got %q", got) } } @@ -1232,34 +1232,34 @@ func TestServiceRunDefaultBuilderUsesGenericToolManagerMicroCompactPolicies(t *t session := agentsession.New("preserve history by manager") session.ID = "session-preserve-history-manager" session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older user"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "preserve_tool", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "preserved result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("preserved result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, } store.sessions[session.ID] = cloneSession(session) scripted := &scriptedProvider{ responses: []scriptedResponse{{ - Message: providertypes.Message{Role: providertypes.RoleAssistant, Content: "done"}, + Message: providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}}, FinishReason: "stop", }}, } @@ -1268,7 +1268,7 @@ func TestServiceRunDefaultBuilderUsesGenericToolManagerMicroCompactPolicies(t *t if err := service.Run(context.Background(), UserInput{ SessionID: session.ID, RunID: "run-preserve-history-generic-manager", - Content: "latest explicit instruction", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}, }); err != nil { t.Fatalf("Run() error = %v", err) } @@ -1276,7 +1276,7 @@ func TestServiceRunDefaultBuilderUsesGenericToolManagerMicroCompactPolicies(t *t if len(scripted.requests) != 1 { t.Fatalf("expected 1 provider request, got %d", len(scripted.requests)) } - if got := scripted.requests[0].Messages[2].Content; got != "preserved result" { + if got := renderPartsForTest(scripted.requests[0].Messages[2].Parts); got != "preserved result" { t.Fatalf("expected preserved tool result to remain visible, got %q", got) } } @@ -1303,7 +1303,7 @@ func TestServiceRunFailurePreservesExistingSessionProviderAndModel(t *testing.T) session.Provider = config.OpenAIName session.Model = "openai-original-model" session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "earlier"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("earlier")}}, } store.sessions[session.ID] = cloneSession(session) @@ -1316,7 +1316,7 @@ func TestServiceRunFailurePreservesExistingSessionProviderAndModel(t *testing.T) err := service.Run(context.Background(), UserInput{ SessionID: session.ID, RunID: "run-preserve-metadata", - Content: "continue", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue")}, }) if err == nil || !containsError(err, "factory failed") { t.Fatalf("expected factory failure, got %v", err) @@ -1332,7 +1332,7 @@ func TestServiceRunFailurePreservesExistingSessionProviderAndModel(t *testing.T) if saved.Model != "openai-original-model" { t.Fatalf("expected model to remain %q, got %q", "openai-original-model", saved.Model) } - if len(saved.Messages) != 2 || saved.Messages[1].Content != "continue" { + if len(saved.Messages) != 2 || renderPartsForTest(saved.Messages[1].Parts) != "continue" { t.Fatalf("expected failed run to append only user message, got %+v", saved.Messages) } } @@ -1366,7 +1366,7 @@ func TestServiceRunUsesToolManager(t *testing.T) { } service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) - if err := service.Run(context.Background(), UserInput{RunID: "run-tool-manager", Content: "edit file"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-tool-manager", Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit file")}}); err != nil { t.Fatalf("Run() error = %v", err) } @@ -1387,7 +1387,7 @@ func TestServiceRunUsesToolManager(t *testing.T) { foundToolMessage := false for _, message := range session.Messages { if message.Role == providertypes.RoleTool && - message.Content == "tool manager output" && + renderPartsForTest(message.Parts) == "tool manager output" && message.ToolMetadata["tool_name"] == "filesystem_edit" && message.ToolMetadata["path"] == "main.go" { foundToolMessage = true @@ -1441,7 +1441,7 @@ func TestServiceRunWaitsForPermissionResolutionAndContinues(t *testing.T) { service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, nil) runErrCh := make(chan error, 1) go func() { - runErrCh <- service.Run(context.Background(), UserInput{RunID: "run-permission-ask", Content: "fetch private"}) + runErrCh <- service.Run(context.Background(), UserInput{RunID: "run-permission-ask", Parts: []providertypes.ContentPart{providertypes.NewTextPart("fetch private")}}) }() var requestPayload PermissionRequestPayload @@ -1555,7 +1555,7 @@ func TestServiceRunEmitsPermissionResolvedForDeny(t *testing.T) { } service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, nil) - if err := service.Run(context.Background(), UserInput{RunID: "run-permission-deny", Content: "run bash"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-permission-deny", Parts: []providertypes.ContentPart{providertypes.NewTextPart("run bash")}}); err != nil { t.Fatalf("Run() error = %v", err) } if tool.callCount != 0 { @@ -1643,7 +1643,7 @@ func TestServiceRunEmitsRememberScopeWhenSessionRejectMemoryHits(t *testing.T) { FinishReason: "tool_calls", }, { - Message: providertypes.Message{Role: "assistant", Content: "done"}, + Message: providertypes.Message{Role: "assistant", Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}}, FinishReason: "stop", }, }, @@ -1653,7 +1653,7 @@ func TestServiceRunEmitsRememberScopeWhenSessionRejectMemoryHits(t *testing.T) { if err := service.Run(context.Background(), UserInput{ SessionID: "session-memory-reject", RunID: "run-memory-reject", - Content: "fetch private", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("fetch private")}, }); err != nil { t.Fatalf("Run() error = %v", err) } @@ -1691,7 +1691,7 @@ func TestServiceRunHandlesToolManagerSpecError(t *testing.T) { service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{ provider: &scriptedProvider{}, }, nil) - input := UserInput{RunID: "run-tool-spec-error", Content: "hello"} + input := UserInput{RunID: "run-tool-spec-error", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}} err := service.Run(context.Background(), input) if err == nil || !containsError(err, "tool specs unavailable") { t.Fatalf("expected tool spec error, got %v", err) @@ -1719,7 +1719,7 @@ func TestServiceNewWithFactoryDefaultsToolManager(t *testing.T) { }, }, nil) - if err := service.Run(context.Background(), UserInput{RunID: "run-default-tool-manager", Content: "hello"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-default-tool-manager", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}); err != nil { t.Fatalf("Run() error = %v", err) } @@ -1741,7 +1741,8 @@ func TestServiceRunErrorPaths(t *testing.T) { }{ { name: "empty input returns validation error", - input: UserInput{Content: " "}, + input: UserInput{Parts: []providertypes.ContentPart{providertypes.NewTextPart(" ")}}, + provider: &scriptedProvider{}, expectErr: "input content is empty", assert: func(t *testing.T, store *memoryStore, provider *scriptedProvider, tool *stubTool) { t.Helper() @@ -1752,7 +1753,7 @@ func TestServiceRunErrorPaths(t *testing.T) { }, { name: "repeated tool cycles continue until assistant completion", - input: UserInput{RunID: "run-many-tool-cycles", Content: "loop"}, + input: UserInput{RunID: "run-many-tool-cycles", Parts: []providertypes.ContentPart{providertypes.NewTextPart("loop")}}, provider: func() *scriptedProvider { responses := make([]scriptedResponse, 0, 10) for i := 0; i < 9; i++ { @@ -1770,7 +1771,7 @@ func TestServiceRunErrorPaths(t *testing.T) { }) } responses = append(responses, scriptedResponse{ - Message: providertypes.Message{Content: "done after many cycles"}, + Message: providertypes.Message{Parts: []providertypes.ContentPart{providertypes.NewTextPart("done after many cycles")}}, FinishReason: "stop", }) return &scriptedProvider{responses: responses} @@ -1786,14 +1787,14 @@ func TestServiceRunErrorPaths(t *testing.T) { if got := len(session.Messages); got != 20 { t.Fatalf("expected 20 persisted messages after 9 tool cycles and final answer, got %d", got) } - if session.Messages[len(session.Messages)-1].Content != "done after many cycles" { + if renderPartsForTest(session.Messages[len(session.Messages)-1].Parts) != "done after many cycles" { t.Fatalf("expected final assistant reply to be persisted, got %+v", session.Messages[len(session.Messages)-1]) } }, }, { name: "provider factory error emits runtime error", - input: UserInput{RunID: "run-factory-error", Content: "hello"}, + input: UserInput{RunID: "run-factory-error", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, factoryErr: errors.New("factory failed"), expectErr: "factory failed", expectEvents: []EventType{ @@ -1806,7 +1807,7 @@ func TestServiceRunErrorPaths(t *testing.T) { input: UserInput{ SessionID: "existing-session", RunID: "run-existing-session", - Content: "continue", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue")}, }, provider: &scriptedProvider{ streams: [][]providertypes.StreamEvent{ @@ -1819,7 +1820,7 @@ func TestServiceRunErrorPaths(t *testing.T) { CreatedAt: agentsession.New("seed").CreatedAt, UpdatedAt: agentsession.New("seed").UpdatedAt, Messages: []providertypes.Message{ - {Role: "user", Content: "earlier"}, + {Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("earlier")}}, }, }, expectEvents: []EventType{EventUserMessage, EventAgentDone}, @@ -1836,7 +1837,7 @@ func TestServiceRunErrorPaths(t *testing.T) { }, { name: "retryable provider error triggers runtime retry then succeeds", - input: UserInput{RunID: "run-retry-success", Content: "hello"}, + input: UserInput{RunID: "run-retry-success", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, provider: func() *scriptedProvider { callIdx := 0 return &scriptedProvider{ @@ -1867,14 +1868,14 @@ func TestServiceRunErrorPaths(t *testing.T) { if len(session.Messages) != 2 { t.Fatalf("expected user + assistant messages, got %d", len(session.Messages)) } - if session.Messages[1].Content != "recovered" { - t.Fatalf("expected assistant content %q, got %q", "recovered", session.Messages[1].Content) + if renderPartsForTest(session.Messages[1].Parts) != "recovered" { + t.Fatalf("expected assistant content %q, got %q", "recovered", renderPartsForTest(session.Messages[1].Parts)) } }, }, { name: "non-retryable provider error does not trigger runtime retry", - input: UserInput{RunID: "run-no-retry", Content: "hello"}, + input: UserInput{RunID: "run-no-retry", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, provider: &scriptedProvider{ name: "auth-error-no-retry", chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { @@ -1897,7 +1898,7 @@ func TestServiceRunErrorPaths(t *testing.T) { }, { name: "runtime retry exhausted emits error", - input: UserInput{RunID: "run-retry-exhausted", Content: "hello"}, + input: UserInput{RunID: "run-retry-exhausted", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, provider: &scriptedProvider{ name: "always-500", chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { @@ -1985,7 +1986,7 @@ func TestServiceCancelActiveRun(t *testing.T) { service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) errCh := make(chan error, 1) - input := UserInput{RunID: "run-cancel-active", Content: "hello"} + input := UserInput{RunID: "run-cancel-active", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}} go func() { errCh <- service.Run(context.Background(), input) @@ -2033,7 +2034,7 @@ func TestServiceRunSameSessionConcurrentNoMessageLoss(t *testing.T) { errCh1 := make(chan error, 1) errCh2 := make(chan error, 1) go func() { - errCh1 <- service.Run(context.Background(), UserInput{SessionID: session.ID, RunID: "run-same-1", Content: "hello-1"}) + errCh1 <- service.Run(context.Background(), UserInput{SessionID: session.ID, RunID: "run-same-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello-1")}}) }() select { @@ -2043,7 +2044,7 @@ func TestServiceRunSameSessionConcurrentNoMessageLoss(t *testing.T) { } go func() { - errCh2 <- service.Run(context.Background(), UserInput{SessionID: session.ID, RunID: "run-same-2", Content: "hello-2"}) + errCh2 <- service.Run(context.Background(), UserInput{SessionID: session.ID, RunID: "run-same-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello-2")}}) }() time.Sleep(120 * time.Millisecond) @@ -2103,7 +2104,7 @@ func TestServiceRunCanceledByProvider(t *testing.T) { service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) - input := UserInput{RunID: "run-provider-cancel", Content: "hello"} + input := UserInput{RunID: "run-provider-cancel", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}} go func() { errCh <- service.Run(ctx, input) @@ -2148,7 +2149,7 @@ func TestServiceRunPreservesProviderErrorAfterCancel(t *testing.T) { service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) - input := UserInput{RunID: "run-provider-error-after-cancel", Content: "hello"} + input := UserInput{RunID: "run-provider-error-after-cancel", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}} go func() { errCh <- service.Run(ctx, input) @@ -2202,7 +2203,7 @@ func TestServiceRunCanceledDuringToolExecution(t *testing.T) { service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) - input := UserInput{RunID: "run-tool-cancel", Content: "edit file"} + input := UserInput{RunID: "run-tool-cancel", Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit file")}} go func() { errCh <- service.Run(ctx, input) @@ -2263,7 +2264,7 @@ func TestServiceRunPreservesToolErrorAfterCancel(t *testing.T) { service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) - input := UserInput{RunID: "run-tool-error-after-cancel", Content: "edit file"} + input := UserInput{RunID: "run-tool-error-after-cancel", Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit file")}} go func() { errCh <- service.Run(ctx, input) @@ -2307,7 +2308,7 @@ func TestServiceRunPreservesSessionSaveErrorAfterCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - input := UserInput{RunID: "run-save-error-after-cancel", Content: "hello"} + input := UserInput{RunID: "run-save-error-after-cancel", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}} err := service.Run(ctx, input) if !errors.Is(err, saveErr) { t.Fatalf("expected save error %q, got %v", saveErr, err) @@ -2352,7 +2353,7 @@ func TestServiceRunToolTimeoutIsNotCancellation(t *testing.T) { } service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) - input := UserInput{RunID: "run-tool-timeout", Content: "edit file"} + input := UserInput{RunID: "run-tool-timeout", Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit file")}} if err := service.Run(context.Background(), input); err != nil { t.Fatalf("Run() error = %v", err) } @@ -2376,9 +2377,9 @@ func TestServiceCompactManualAppliesAndPersists(t *testing.T) { session := agentsession.New("manual") session.ID = "session-manual" session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older"}, - {Role: providertypes.RoleAssistant, Content: "older answer"}, - {Role: providertypes.RoleUser, Content: "before"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("before")}}, } store.sessions[session.ID] = cloneSession(session) @@ -2389,8 +2390,8 @@ func TestServiceCompactManualAppliesAndPersists(t *testing.T) { service.compactRunner = &stubCompactRunner{ result: contextcompact.Result{ Messages: []providertypes.Message{ - {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- ok\n\nin_progress:\n- continue"}, - {Role: providertypes.RoleAssistant, Content: "latest"}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("[compact_summary]\ndone:\n- ok\n\nin_progress:\n- continue")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest")}}, }, Applied: true, Metrics: contextcompact.Metrics{ @@ -2419,7 +2420,7 @@ func TestServiceCompactManualAppliesAndPersists(t *testing.T) { if err != nil { t.Fatalf("load compacted session: %v", err) } - if len(saved.Messages) != 2 || !strings.Contains(saved.Messages[0].Content, "compact_summary") { + if len(saved.Messages) != 2 || !strings.Contains(renderPartsForTest(saved.Messages[0].Parts), "compact_summary") { t.Fatalf("expected persisted compacted messages, got %+v", saved.Messages) } @@ -2434,9 +2435,9 @@ func TestServiceCompactManualFailureReturnsError(t *testing.T) { session := agentsession.New("manual-fail") session.ID = "session-manual-fail" session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older"}, - {Role: providertypes.RoleAssistant, Content: "older answer"}, - {Role: providertypes.RoleUser, Content: "before"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("before")}}, } store.sessions[session.ID] = cloneSession(session) @@ -2458,7 +2459,7 @@ func TestServiceCompactManualFailureReturnsError(t *testing.T) { if err != nil { t.Fatalf("load original session: %v", err) } - if len(saved.Messages) != 3 || saved.Messages[2].Content != "before" { + if len(saved.Messages) != 3 || renderPartsForTest(saved.Messages[2].Parts) != "before" { t.Fatalf("expected original session untouched, got %+v", saved.Messages) } @@ -2491,9 +2492,9 @@ func TestServiceCompactUsesSessionProviderAndModelWhenPresent(t *testing.T) { session.Provider = config.OpenAIName session.Model = "session-model" session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older"}, - {Role: providertypes.RoleAssistant, Content: "older answer"}, - {Role: providertypes.RoleUser, Content: "before"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("before")}}, } store.sessions[session.ID] = cloneSession(session) @@ -2556,9 +2557,9 @@ func TestServiceCompactFallsBackToCurrentProviderWhenSessionMetadataMissing(t *t session := agentsession.New("manual-fallback") session.ID = "session-manual-fallback" session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older"}, - {Role: providertypes.RoleAssistant, Content: "older answer"}, - {Role: providertypes.RoleUser, Content: "before"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older answer")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("before")}}, } store.sessions[session.ID] = cloneSession(session) @@ -2597,8 +2598,8 @@ func TestServiceManualCompactThenRunContinuesToolRound(t *testing.T) { session := agentsession.New("manual-continue") session.ID = "session-manual-continue" session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "legacy request"}, - {Role: providertypes.RoleAssistant, Content: "legacy answer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("legacy request")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("legacy answer")}}, } store.sessions[session.ID] = cloneSession(session) @@ -2621,8 +2622,8 @@ func TestServiceManualCompactThenRunContinuesToolRound(t *testing.T) { runFn: func(ctx context.Context, input contextcompact.Input) (contextcompact.Result, error) { return contextcompact.Result{ Messages: []providertypes.Message{ - {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, - {Role: providertypes.RoleAssistant, Content: "latest answer"}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest answer")}}, }, Applied: true, Metrics: contextcompact.Metrics{ @@ -2646,7 +2647,7 @@ func TestServiceManualCompactThenRunContinuesToolRound(t *testing.T) { if err := service.Run(context.Background(), UserInput{ SessionID: session.ID, RunID: "run-after-manual", - Content: "continue", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue")}, }); err != nil { t.Fatalf("Run() error = %v", err) } @@ -2659,7 +2660,7 @@ func TestServiceManualCompactThenRunContinuesToolRound(t *testing.T) { if err != nil { t.Fatalf("load session: %v", err) } - if len(saved.Messages) < 6 || !strings.Contains(saved.Messages[0].Content, "compact_summary") { + if len(saved.Messages) < 6 || !strings.Contains(renderPartsForTest(saved.Messages[0].Parts), "compact_summary") { t.Fatalf("expected compacted history + new tool round, got %+v", saved.Messages) } @@ -2721,7 +2722,7 @@ func TestServiceSerializesRunAndCompact(t *testing.T) { runErrCh <- service.Run(context.Background(), UserInput{ SessionID: session.ID, RunID: "run-serialized", - Content: "hello", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}, }) }() @@ -2833,7 +2834,7 @@ func TestServiceRunUsesSessionWorkdirForContextAndTools(t *testing.T) { if err := service.Run(context.Background(), UserInput{ SessionID: session.ID, RunID: "run-session-workdir", - Content: "edit", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit")}, }); err != nil { t.Fatalf("Run() error = %v", err) } @@ -2870,7 +2871,7 @@ func TestServiceRunUsesInputWorkdirForNewSession(t *testing.T) { service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) if err := service.Run(context.Background(), UserInput{ RunID: "run-new-session-workdir", - Content: "hello", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}, Workdir: draftRoot, }); err != nil { t.Fatalf("Run() error = %v", err) @@ -3274,8 +3275,8 @@ func TestServiceRunFailsWhenInitialUserMessageSaveFails(t *testing.T) { service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) err := service.Run(context.Background(), UserInput{ - RunID: "run-initial-save-fail", - Content: "hello", + RunID: "run-initial-save-fail", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}, }) if err == nil || !containsError(err, "save failed on first write") { t.Fatalf("expected initial save error, got %v", err) @@ -3302,8 +3303,8 @@ func TestServiceRunFailsWhenAssistantSaveFails(t *testing.T) { } service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) err := service.Run(context.Background(), UserInput{ - RunID: "run-assistant-save-fail", - Content: "hello", + RunID: "run-assistant-save-fail", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}, }) if err == nil || !containsError(err, "save failed on assistant") { t.Fatalf("expected assistant save error, got %v", err) @@ -3389,7 +3390,7 @@ func TestCallProviderWithRetryReturnsCombinedForwardError(t *testing.T) { request: providertypes.GenerateRequest{ Model: "test-model", SystemPrompt: "prompt", - Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, }, } @@ -3439,8 +3440,8 @@ func TestServiceRunPersistsAndRestoresTokenUsage(t *testing.T) { service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) if err := service.Run(context.Background(), UserInput{ - RunID: "run-token-usage-first", - Content: "hello", + RunID: "run-token-usage-first", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}, }); err != nil { t.Fatalf("first Run() error = %v", err) } @@ -3489,7 +3490,7 @@ func TestServiceRunPersistsAndRestoresTokenUsage(t *testing.T) { if err := service.Run(context.Background(), UserInput{ SessionID: firstSession.ID, RunID: "run-token-usage-second", - Content: "continue", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue")}, }); err != nil { t.Fatalf("second Run() error = %v", err) } @@ -3557,8 +3558,8 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { session.TokenInputTotal = 100 session.TokenOutputTotal = 40 session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older request"}, - {Role: providertypes.RoleAssistant, Content: "older answer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older request")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older answer")}}, } store.sessions[session.ID] = cloneSession(session) @@ -3586,7 +3587,7 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { FinishReason: "tool_calls", }, { - Message: providertypes.Message{Content: "done"}, + Message: providertypes.Message{Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}}, FinishReason: "stop", }, }, @@ -3596,8 +3597,8 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { compactRunner := &stubCompactRunner{ result: contextcompact.Result{ Messages: []providertypes.Message{ - {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, - {Role: providertypes.RoleAssistant, Content: "latest answer"}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest answer")}}, }, Applied: true, Metrics: contextcompact.Metrics{ @@ -3615,7 +3616,7 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { if err := service.Run(context.Background(), UserInput{ SessionID: session.ID, RunID: "run-auto-compact", - Content: "continue", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue")}, }); err != nil { t.Fatalf("Run() error = %v", err) } @@ -3650,10 +3651,10 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { if len(scripted.requests[0].Messages) != 2 { t.Fatalf("expected rebuilt compacted context to be sent, got %+v", scripted.requests[0].Messages) } - if scripted.requests[0].Messages[0].Content != "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue" { + if renderPartsForTest(scripted.requests[0].Messages[0].Parts) != "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue" { t.Fatalf("expected first provider request to use compact summary, got %+v", scripted.requests[0].Messages) } - if scripted.requests[0].Messages[1].Content != "latest answer" { + if renderPartsForTest(scripted.requests[0].Messages[1].Parts) != "latest answer" { t.Fatalf("expected first provider request to use compacted latest answer, got %+v", scripted.requests[0].Messages) } @@ -3718,8 +3719,8 @@ func TestServiceRunAutoCompactNoopDoesNotDisableReactiveRetry(t *testing.T) { session.ID = "session-auto-noop-reactive" session.TokenInputTotal = 100 session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older request"}, - {Role: providertypes.RoleAssistant, Content: "older answer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older request")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older answer")}}, } store.sessions[session.ID] = cloneSession(session) @@ -3778,8 +3779,8 @@ func TestServiceRunAutoCompactNoopDoesNotDisableReactiveRetry(t *testing.T) { case contextcompact.ModeReactive: return contextcompact.Result{ Messages: []providertypes.Message{ - {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, - {Role: providertypes.RoleUser, Content: "continue"}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue")}}, }, Applied: true, Metrics: contextcompact.Metrics{ @@ -3800,7 +3801,7 @@ func TestServiceRunAutoCompactNoopDoesNotDisableReactiveRetry(t *testing.T) { if err := service.Run(context.Background(), UserInput{ SessionID: session.ID, RunID: "run-auto-noop-reactive", - Content: "continue", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue")}, }); err != nil { t.Fatalf("Run() error = %v", err) } @@ -3829,8 +3830,8 @@ func TestServiceRunReactivelyCompactsOnContextTooLong(t *testing.T) { session.TokenInputTotal = 220 session.TokenOutputTotal = 70 session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older request"}, - {Role: providertypes.RoleAssistant, Content: "older answer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older request")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older answer")}}, } store.sessions[session.ID] = cloneSession(session) @@ -3876,8 +3877,8 @@ func TestServiceRunReactivelyCompactsOnContextTooLong(t *testing.T) { service.compactRunner = &stubCompactRunner{ result: contextcompact.Result{ Messages: []providertypes.Message{ - {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, - {Role: providertypes.RoleUser, Content: "continue"}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue")}}, }, Applied: true, Metrics: contextcompact.Metrics{ @@ -3894,7 +3895,7 @@ func TestServiceRunReactivelyCompactsOnContextTooLong(t *testing.T) { if err := service.Run(context.Background(), UserInput{ SessionID: session.ID, RunID: "run-reactive-compact", - Content: "continue", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue")}, }); err != nil { t.Fatalf("Run() error = %v", err) } @@ -3929,8 +3930,8 @@ func TestServiceRunReactivelyCompactsOnContextTooLong(t *testing.T) { if len(saved.Messages) != 3 { t.Fatalf("expected compacted transcript plus final assistant reply, got %+v", saved.Messages) } - if saved.Messages[2].Content != "recovered" { - t.Fatalf("expected final assistant reply %q, got %q", "recovered", saved.Messages[2].Content) + if renderPartsForTest(saved.Messages[2].Parts) != "recovered" { + t.Fatalf("expected final assistant reply %q, got %q", "recovered", renderPartsForTest(saved.Messages[2].Parts)) } events := collectRuntimeEvents(service.Events()) @@ -3971,8 +3972,8 @@ func TestServiceRunReactiveCompactRetriesWithinSameRun(t *testing.T) { session.ID = "session-reactive-single-loop" session.TokenInputTotal = 160 session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older request"}, - {Role: providertypes.RoleAssistant, Content: "older answer"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older request")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older answer")}}, } store.sessions[session.ID] = cloneSession(session) @@ -4006,8 +4007,8 @@ func TestServiceRunReactiveCompactRetriesWithinSameRun(t *testing.T) { service.compactRunner = &stubCompactRunner{ result: contextcompact.Result{ Messages: []providertypes.Message{ - {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, - {Role: providertypes.RoleUser, Content: "continue"}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue")}}, }, Applied: true, Metrics: contextcompact.Metrics{ @@ -4022,7 +4023,7 @@ func TestServiceRunReactiveCompactRetriesWithinSameRun(t *testing.T) { if err := service.Run(context.Background(), UserInput{ SessionID: session.ID, RunID: "run-reactive-single-loop", - Content: "continue", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue")}, }); err != nil { t.Fatalf("Run() should recover after reactive compact, got %v", err) } @@ -4066,8 +4067,8 @@ func TestServiceRunReactiveCompactDegradesUpToMaxAttempts(t *testing.T) { } err := service.Run(context.Background(), UserInput{ - RunID: "run-reactive-compact-once", - Content: "continue", + RunID: "run-reactive-compact-once", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue")}, }) if err == nil || !containsError(err, "prompt is too long") { t.Fatalf("expected final context-too-long error, got %v", err) @@ -4128,8 +4129,8 @@ func TestServiceRunDoesNotReactiveCompactOnPlainTextTokenThrottle(t *testing.T) service.compactRunner = &stubCompactRunner{} err := service.Run(context.Background(), UserInput{ - RunID: "run-plain-text-token-throttle", - Content: "continue", + RunID: "run-plain-text-token-throttle", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue")}, }) if err == nil || !containsError(err, throttleErr.Error()) { t.Fatalf("expected plain text token throttle error, got %v", err) @@ -4611,8 +4612,8 @@ func TestParallelToolCallsPhaseMigration(t *testing.T) { }, { Message: providertypes.Message{ - Role: providertypes.RoleAssistant, - Content: "done", + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}, }, FinishReason: "stop", }, @@ -4629,8 +4630,8 @@ func TestParallelToolCallsPhaseMigration(t *testing.T) { ) input := UserInput{ - RunID: "run-parallel", - Content: "run parallel tools", + RunID: "run-parallel", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("run parallel tools")}, } if err := service.Run(context.Background(), input); err != nil { @@ -4719,8 +4720,8 @@ func TestParallelToolCallsRespectConcurrencyLimit(t *testing.T) { }, { Message: providertypes.Message{ - Role: providertypes.RoleAssistant, - Content: "done", + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}, }, FinishReason: "stop", }, @@ -4736,7 +4737,7 @@ func TestParallelToolCallsRespectConcurrencyLimit(t *testing.T) { nil, ) - if err := service.Run(context.Background(), UserInput{RunID: "run-parallel-limit", Content: "parallel"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-parallel-limit", Parts: []providertypes.ContentPart{providertypes.NewTextPart("parallel")}}); err != nil { t.Fatalf("Run() failed: %v", err) } @@ -4779,8 +4780,8 @@ func TestParallelToolCallsSerializeSameToolName(t *testing.T) { }, { Message: providertypes.Message{ - Role: providertypes.RoleAssistant, - Content: "done", + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}, }, FinishReason: "stop", }, @@ -4796,7 +4797,7 @@ func TestParallelToolCallsSerializeSameToolName(t *testing.T) { nil, ) - if err := service.Run(context.Background(), UserInput{RunID: "run-parallel-lock", Content: "parallel"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-parallel-lock", Parts: []providertypes.ContentPart{providertypes.NewTextPart("parallel")}}); err != nil { t.Fatalf("Run() failed: %v", err) } @@ -4863,7 +4864,7 @@ func TestParallelToolCallsStopDispatchAfterFirstError(t *testing.T) { nil, ) - err := service.Run(context.Background(), UserInput{RunID: "run-first-error-stop-dispatch", Content: "parallel"}) + err := service.Run(context.Background(), UserInput{RunID: "run-first-error-stop-dispatch", Parts: []providertypes.ContentPart{providertypes.NewTextPart("parallel")}}) if err == nil { t.Fatalf("expected run error when first tool result save fails") } @@ -4885,8 +4886,8 @@ func TestAgentDoneEventCarriesRunScopedEnvelope(t *testing.T) { responses: []scriptedResponse{ { Message: providertypes.Message{ - Role: providertypes.RoleAssistant, - Content: "done", + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}, }, FinishReason: "stop", }, @@ -4896,7 +4897,7 @@ func TestAgentDoneEventCarriesRunScopedEnvelope(t *testing.T) { nil, ) - if err := service.Run(context.Background(), UserInput{RunID: "run-agent-done-envelope", Content: "hello"}); err != nil { + if err := service.Run(context.Background(), UserInput{RunID: "run-agent-done-envelope", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}); err != nil { t.Fatalf("Run() failed: %v", err) } diff --git a/internal/runtime/session_mutation.go b/internal/runtime/session_mutation.go index 81c5844b..1738fa13 100644 --- a/internal/runtime/session_mutation.go +++ b/internal/runtime/session_mutation.go @@ -12,10 +12,10 @@ import ( const toolNameMetadataKey = "tool_name" // appendUserMessageAndSave 将用户消息追加到会话并立即持久化。 -func (s *Service) appendUserMessageAndSave(ctx context.Context, state *runState, content string) error { +func (s *Service) appendUserMessageAndSave(ctx context.Context, state *runState, parts []providertypes.ContentPart) error { message := providertypes.Message{ - Role: providertypes.RoleUser, - Content: content, + Role: providertypes.RoleUser, + Parts: parts, } state.session.Messages = append(state.session.Messages, message) state.touchSession() @@ -37,7 +37,7 @@ func (s *Service) appendAssistantMessageAndSave( state.session.Provider = snapshot.providerConfig.Name state.session.Model = snapshot.model - if strings.TrimSpace(assistant.Content) != "" || len(assistant.ToolCalls) > 0 { + if !assistant.IsEmpty() { state.session.Messages = append(state.session.Messages, assistant) state.touchSession() return s.sessionStore.Save(ctx, &state.session) @@ -80,7 +80,7 @@ func normalizeToolMessageForPersistence(call providertypes.ToolCall, result tool return providertypes.Message{ Role: providertypes.RoleTool, - Content: content, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(content)}, ToolCallID: call.ID, IsError: result.IsError, ToolMetadata: sanitizedMetadata, @@ -127,6 +127,7 @@ func cloneMessagesForPersistence(messages []providertypes.Message) []providertyp cloned := make([]providertypes.Message, len(messages)) for idx, message := range messages { next := message + next.Parts = providertypes.CloneParts(message.Parts) if len(message.ToolCalls) > 0 { next.ToolCalls = append([]providertypes.ToolCall(nil), message.ToolCalls...) } else { diff --git a/internal/runtime/skills_test.go b/internal/runtime/skills_test.go index 5fb72c9b..545559e3 100644 --- a/internal/runtime/skills_test.go +++ b/internal/runtime/skills_test.go @@ -492,7 +492,7 @@ func TestServiceRunReinjectsSkillsAfterAutoCompact(t *testing.T) { }, }) - if err := service.Run(context.Background(), UserInput{SessionID: session.ID, RunID: "run-auto-compact-skills", Content: "hello"}); err != nil { + if err := service.Run(context.Background(), UserInput{SessionID: session.ID, RunID: "run-auto-compact-skills", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}); err != nil { t.Fatalf("Run() error = %v", err) } if len(builder.builds) < 2 { diff --git a/internal/runtime/todo_runtime_integration_test.go b/internal/runtime/todo_runtime_integration_test.go index 1547b26a..8eefc8f2 100644 --- a/internal/runtime/todo_runtime_integration_test.go +++ b/internal/runtime/todo_runtime_integration_test.go @@ -35,8 +35,8 @@ func TestServiceRunTodoWriteToolCall(t *testing.T) { }, { Message: providertypes.Message{ - Role: providertypes.RoleAssistant, - Content: "done", + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}, }, FinishReason: "stop", }, @@ -52,8 +52,8 @@ func TestServiceRunTodoWriteToolCall(t *testing.T) { ) if err := service.Run(context.Background(), UserInput{ - RunID: "run-todo-tool", - Content: "请记录一个待办并继续", + RunID: "run-todo-tool", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("请记录一个待办并继续")}, }); err != nil { t.Fatalf("Run() error = %v", err) } diff --git a/internal/session/parts_render_test.go b/internal/session/parts_render_test.go new file mode 100644 index 00000000..ccd47b6b --- /dev/null +++ b/internal/session/parts_render_test.go @@ -0,0 +1,20 @@ +package session + +import ( + "strings" + + providertypes "neo-code/internal/provider/types" +) + +func renderPartsForTest(parts []providertypes.ContentPart) string { + var builder strings.Builder + for _, part := range parts { + switch part.Kind { + case providertypes.ContentPartText: + builder.WriteString(part.Text) + case providertypes.ContentPartImage: + builder.WriteString("[Image]") + } + } + return builder.String() +} diff --git a/internal/session/skill_activation_test.go b/internal/session/skill_activation_test.go index 082fca71..10be3db3 100644 --- a/internal/session/skill_activation_test.go +++ b/internal/session/skill_activation_test.go @@ -57,7 +57,7 @@ func TestJSONStoreSaveLoadRoundTripActivatedSkills(t *testing.T) { {SkillID: "go_review"}, {SkillID: "go-review"}, }, - Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, } if err := store.Save(context.Background(), session); err != nil { @@ -94,7 +94,7 @@ func TestJSONStoreLoadAllowsMissingActivatedSkillsField(t *testing.T) { mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "no-activated-skills.json"), strings.Join([]string{ `{`, - ` "schema_version": 1,`, + ` "schema_version": 2,`, ` "id": "no-activated-skills",`, ` "title": "No Activated Skills",`, ` "created_at": "2026-04-15T10:00:00Z",`, diff --git a/internal/session/store_test.go b/internal/session/store_test.go index ec1c9834..02c2b651 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -31,8 +31,8 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { CreatedAt: time.Now().Add(-2 * time.Hour), UpdatedAt: time.Now().Add(-1 * time.Hour), Messages: []providertypes.Message{ - {Role: "user", Content: "hello"}, - {Role: "assistant", Content: "world"}, + {Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, + {Role: "assistant", Parts: []providertypes.ContentPart{providertypes.NewTextPart("world")}}, }, } newer := &Session{ @@ -43,7 +43,7 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { UpdatedAt: time.Now(), Workdir: t.TempDir(), Messages: []providertypes.Message{ - {Role: "user", Content: "new"}, + {Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("new")}}, }, } @@ -64,7 +64,7 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { if loaded.Workdir != older.Workdir { t.Fatalf("expected persisted workdir %q, got %q", older.Workdir, loaded.Workdir) } - if len(loaded.Messages) != 2 || loaded.Messages[1].Content != "world" { + if len(loaded.Messages) != 2 || renderPartsForTest(loaded.Messages[1].Parts) != "world" { t.Fatalf("unexpected loaded messages: %+v", loaded.Messages) } @@ -230,7 +230,7 @@ func TestJSONStoreCorruptedSessionBehaviors(t *testing.T) { Title: "Valid Session", CreatedAt: time.Now().Add(-time.Minute), UpdatedAt: time.Now(), - Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, } if err := store.Save(context.Background(), valid); err != nil { t.Fatalf("Save valid session: %v", err) @@ -525,7 +525,7 @@ func TestJSONStoreLoadRejectsMissingTaskState(t *testing.T) { mustWriteSessionFile( t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "missing-task-state.json"), - `{"schema_version":1,"id":"missing-task-state","title":"x","messages":[]}`, + `{"schema_version":2,"id":"missing-task-state","title":"x","messages":[]}`, ) _, err := store.Load(context.Background(), "missing-task-state") @@ -557,7 +557,7 @@ func TestJSONStoreListSummariesSkipsUnreadableAndMalformedEntries(t *testing.T) mustWriteSessionFile( t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "missing-task-state-summary.json"), - `{"schema_version":1,"id":"missing-task-state-summary","title":"x","created_at":"2026-04-13T00:00:00Z","updated_at":"2026-04-13T00:00:00Z"}`, + `{"schema_version":2,"id":"missing-task-state-summary","title":"x","created_at":"2026-04-13T00:00:00Z","updated_at":"2026-04-13T00:00:00Z"}`, ) summaries, err := store.ListSummaries(context.Background()) @@ -586,10 +586,10 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now(), Messages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "hello"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, { - Role: providertypes.RoleAssistant, - Content: "calling tool", + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("calling tool")}, ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "webfetch", Arguments: `{"url":"https://example.com"}`}, }, @@ -597,7 +597,7 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { { Role: providertypes.RoleTool, ToolCallID: "call-1", - Content: "ok", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("ok")}, ToolMetadata: map[string]string{ "tool_name": "webfetch", "http_status": "200", @@ -657,7 +657,7 @@ func TestJSONStoreSaveRoundTripsMetadataOnlyToolMessage(t *testing.T) { CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now(), Messages: []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "inspect"}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("inspect")}}, { Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{ @@ -667,7 +667,7 @@ func TestJSONStoreSaveRoundTripsMetadataOnlyToolMessage(t *testing.T) { { Role: providertypes.RoleTool, ToolCallID: "call-1", - Content: "", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("")}, ToolMetadata: map[string]string{ "tool_name": "filesystem_read_file", "path": "README.md", @@ -684,8 +684,8 @@ func TestJSONStoreSaveRoundTripsMetadataOnlyToolMessage(t *testing.T) { if err != nil { t.Fatalf("load saved session: %v", err) } - if loaded.Messages[2].Content != "" { - t.Fatalf("expected empty content to round-trip, got %q", loaded.Messages[2].Content) + if renderPartsForTest(loaded.Messages[2].Parts) != "" { + t.Fatalf("expected empty content to round-trip, got %q", renderPartsForTest(loaded.Messages[2].Parts)) } if loaded.Messages[2].ToolMetadata["tool_name"] != "filesystem_read_file" || loaded.Messages[2].ToolMetadata["path"] != "README.md" { @@ -697,7 +697,7 @@ func TestDecodeStoredSummaryUsesLightweightMetadataPath(t *testing.T) { t.Parallel() summary, err := decodeStoredSummary([]byte(`{ - "schema_version": 1, + "schema_version": 2, "id": "summary-only", "title": "Summary Only", "created_at": "2026-04-13T08:00:00Z", @@ -782,7 +782,7 @@ func TestJSONStoreLoadClampsOversizedTaskState(t *testing.T) { payload := strings.Join([]string{ `{`, - ` "schema_version": 1,`, + ` "schema_version": 2,`, ` "id": "task-state-clamp-load",`, ` "title": "Clamp Load",`, ` "created_at": "2026-04-13T08:00:00Z",`, @@ -845,15 +845,14 @@ func TestJSONStoreSaveLoadRoundTripTodos(t *testing.T) { UpdatedAt: updatedAt, }, { - ID: "todo-2", - Content: "persist todos in session", + ID: "todo-2", Content: "persist todos in session", Status: TodoStatusInProgress, Priority: 2, CreatedAt: createdAt, UpdatedAt: updatedAt, }, }, - Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, } if err := store.Save(context.Background(), session); err != nil { @@ -890,7 +889,7 @@ func TestJSONStoreLoadAllowsMissingTodosField(t *testing.T) { mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "no-todos.json"), strings.Join([]string{ `{`, - ` "schema_version": 1,`, + ` "schema_version": 2,`, ` "id": "no-todos",`, ` "title": "No Todos",`, ` "created_at": "2026-04-14T10:00:00Z",`, @@ -951,7 +950,7 @@ func TestJSONStoreLoadRejectsInvalidTodos(t *testing.T) { mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "invalid-todos-load.json"), strings.Join([]string{ `{`, - ` "schema_version": 1,`, + ` "schema_version": 2,`, ` "id": "invalid-todos-load",`, ` "title": "Invalid Todos Load",`, ` "created_at": "2026-04-14T10:00:00Z",`, diff --git a/internal/session/task_state.go b/internal/session/task_state.go index 2408f52e..7bb33379 100644 --- a/internal/session/task_state.go +++ b/internal/session/task_state.go @@ -7,7 +7,7 @@ import ( const ( // CurrentSchemaVersion 表示当前会话持久化结构的唯一合法版本。 - CurrentSchemaVersion = 1 + CurrentSchemaVersion = 2 // taskStateMaxFieldChars 限制 TaskState 单值字段的最大字符数,避免异常大文本污染持久化与后续 prompt。 taskStateMaxFieldChars = 2000 diff --git a/internal/tools/format.go b/internal/tools/format.go index 020a57fe..a329f385 100644 --- a/internal/tools/format.go +++ b/internal/tools/format.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "neo-code/internal/partsrender" providertypes "neo-code/internal/provider/types" ) @@ -122,13 +123,19 @@ func FormatToolMessageForModel(message providertypes.Message) string { lines = append(lines, fmt.Sprintf("truncated: %t", toolMessageTruncated(message.ToolMetadata))) lines = append(lines, formatToolMetadataLines(message.ToolMetadata)...) - if strings.TrimSpace(message.Content) != "" { - lines = append(lines, "", "content:", message.Content) + content := renderToolMessageParts(message.Parts) + if strings.TrimSpace(content) != "" { + lines = append(lines, "", "content:", content) } return strings.Join(lines, "\n") } +// renderToolMessageParts 将工具消息分片渲染为模型可消费的文本,图片仅保留安全占位。 +func renderToolMessageParts(parts []providertypes.ContentPart) string { + return partsrender.RenderDisplayParts(parts) +} + // FormatError builds a consistent error payload for tool failures. func FormatError(toolName string, reason string, details string) string { toolName = strings.TrimSpace(toolName) diff --git a/internal/tools/format_test.go b/internal/tools/format_test.go index e916c0b6..6b715df0 100644 --- a/internal/tools/format_test.go +++ b/internal/tools/format_test.go @@ -268,7 +268,7 @@ func TestFormatToolMessageForModel(t *testing.T) { message := providertypes.Message{ Role: providertypes.RoleTool, - Content: "ok", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("ok")}, ToolCallID: "call-1", ToolMetadata: map[string]string{ "tool_name": "filesystem_edit", diff --git a/internal/tui/core/app/input_features.go b/internal/tui/core/app/input_features.go index 8c56f9fe..c52d4a3b 100644 --- a/internal/tui/core/app/input_features.go +++ b/internal/tui/core/app/input_features.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "path/filepath" - "strconv" "strings" tea "github.com/charmbracelet/bubbletea" @@ -430,29 +429,3 @@ func (a *App) canSendImageInput() bool { func (a *App) invalidateModelCapabilityCache() { a.currentModelCapabilities = modelCapabilityState{} } - -// composeMessageWithImageAttachments 在发送前把附件元信息拼接到文本,避免附件在运行链路中丢失。 -func (a *App) composeMessageWithImageAttachments(content string) string { - trimmed := strings.TrimSpace(content) - if len(a.pendingImageAttachments) == 0 { - return trimmed - } - - var builder strings.Builder - builder.WriteString(trimmed) - builder.WriteString("\n\n[Attached images]\n") - for index, attachment := range a.pendingImageAttachments { - builder.WriteString(strconv.Itoa(index + 1)) - builder.WriteString(". ") - builder.WriteString(attachment.Name) - builder.WriteString(" | mime=") - builder.WriteString(attachment.MimeType) - builder.WriteString(" | bytes=") - builder.WriteString(strconv.FormatInt(attachment.Size, 10)) - builder.WriteString(" | path=") - builder.WriteString(attachment.Path) - builder.WriteString("\n") - } - builder.WriteString("Treat the list above as user-provided image attachments.") - return builder.String() -} diff --git a/internal/tui/core/app/input_features_test.go b/internal/tui/core/app/input_features_test.go index db32b19a..b38e0edc 100644 --- a/internal/tui/core/app/input_features_test.go +++ b/internal/tui/core/app/input_features_test.go @@ -200,29 +200,6 @@ func TestCanSendImageInputCacheInvalidationOnModelChange(t *testing.T) { } } -func TestComposeMessageWithImageAttachments(t *testing.T) { - app, _ := newTestApp(t) - app.pendingImageAttachments = []pendingImageAttachment{{ - Path: "/tmp/a.png", - Name: "a.png", - MimeType: "image/png", - Size: 12, - }} - - got := app.composeMessageWithImageAttachments("hello") - if !strings.Contains(got, "[Attached images]") || !strings.Contains(got, "a.png") || !strings.Contains(got, "path=/tmp/a.png") { - t.Fatalf("unexpected composed message: %q", got) - } -} - -func TestComposeMessageWithImageAttachmentsNoAttachments(t *testing.T) { - app, _ := newTestApp(t) - got := app.composeMessageWithImageAttachments(" hello ") - if got != "hello" { - t.Fatalf("expected trimmed content without attachment block, got %q", got) - } -} - func TestApplyImageReference(t *testing.T) { app, _ := newTestApp(t) root := t.TempDir() @@ -416,7 +393,7 @@ func TestRunWorkspaceCommandCmd(t *testing.T) { } } -func TestUpdateSendWithImageAttachmentsComposesRuntimeInput(t *testing.T) { +func TestUpdateSendWithImageAttachmentsBlocksUntilSessionAssets(t *testing.T) { app, runtime := newTestApp(t) root := t.TempDir() imagePath := filepath.Join(root, "queued.png") @@ -441,23 +418,23 @@ func TestUpdateSendWithImageAttachmentsComposesRuntimeInput(t *testing.T) { app.state.InputText = "hello" model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - if cmd == nil { - t.Fatalf("expected run command") + if cmd != nil { + _ = cmd() } app = model.(App) if app.hasImageAttachments() { - t.Fatalf("expected attachments cleared after send") + t.Fatalf("expected attachments cleared after unsupported send path") } - if len(app.activeMessages) == 0 || !strings.Contains(app.activeMessages[len(app.activeMessages)-1].Content, "[Attached images]") { - t.Fatalf("expected composed user message in transcript") + if app.state.IsAgentRunning { + t.Fatalf("expected image send to be blocked until session assets are available") } - - msg := cmd() - _, ok := msg.(runFinishedMsg) - if !ok { - t.Fatalf("expected runFinishedMsg, got %T", msg) + if len(app.activeMessages) != 0 { + t.Fatalf("expected no text fallback message in transcript, got %+v", app.activeMessages) + } + if len(runtime.runInputs) != 0 { + t.Fatalf("expected no runtime input with image metadata fallback, got %+v", runtime.runInputs) } - if len(runtime.runInputs) != 1 || !strings.Contains(runtime.runInputs[0].Content, "[Attached images]") { - t.Fatalf("expected composed runtime input, got %+v", runtime.runInputs) + if app.state.StatusText != "Image attachments need session asset support" { + t.Fatalf("unexpected status text: %q", app.state.StatusText) } } diff --git a/internal/tui/core/app/parts_render.go b/internal/tui/core/app/parts_render.go new file mode 100644 index 00000000..787dc156 --- /dev/null +++ b/internal/tui/core/app/parts_render.go @@ -0,0 +1,11 @@ +package tui + +import ( + "neo-code/internal/partsrender" + providertypes "neo-code/internal/provider/types" +) + +// renderMessagePartsForDisplay 将消息分片渲染为 TUI 展示文本,图片只显示安全占位。 +func renderMessagePartsForDisplay(parts []providertypes.ContentPart) string { + return partsrender.RenderDisplayParts(parts) +} diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index d35b9149..9fea342e 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -350,13 +350,6 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } - // 如果不是立即执行的命令,再执行常规的输入重置 - a.input.Reset() - a.state.InputText = "" - a.applyComponentLayout(true) - a.refreshCommandMenu() - a.resetPasteHeuristics() - switch strings.ToLower(input) { case slashCommandHelp: a.refreshHelpPicker() @@ -425,6 +418,20 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.clearImageAttachments() return a, tea.Batch(cmds...) } + if a.hasImageAttachments() { + a.state.ExecutionError = "image attachments require session asset storage before sending" + a.state.StatusText = "Image attachments need session asset support" + a.appendActivity("multimodal", "Image attachments not sent", "Session asset storage is not available yet; images were not converted to text.", true) + a.clearImageAttachments() + return a, tea.Batch(cmds...) + } + + // 如果不是立即执行的命令,再执行常规的输入重置 + a.input.Reset() + a.state.InputText = "" + a.applyComponentLayout(true) + a.refreshCommandMenu() + a.resetPasteHeuristics() a.clearActivities() a.clearRunProgress() @@ -435,17 +442,12 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.state.StatusText = statusThinking a.state.CurrentTool = "" - if a.hasImageAttachments() { - a.appendActivity("multimodal", "Sending message with image metadata", fmt.Sprintf("%d image(s) attached", len(a.pendingImageAttachments)), false) - } - - composedInput := a.composeMessageWithImageAttachments(input) - a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleUser, Content: composedInput}) + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart(input)}}) a.rebuildTranscript() runID := fmt.Sprintf("run-%d", a.now().UnixNano()) a.state.ActiveRunID = runID requestedWorkdir := tuiutils.RequestedWorkdirForRun(a.state.CurrentWorkdir) - cmds = append(cmds, runAgent(a.runtime, runID, a.state.ActiveSessionID, requestedWorkdir, composedInput)) + cmds = append(cmds, runAgent(a.runtime, runID, a.state.ActiveSessionID, requestedWorkdir, input)) a.clearImageAttachments() return a, tea.Batch(cmds...) } @@ -632,25 +634,24 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil } return a, runModelSelection(a.providerSvc, item.id) - case pickerSession: - item, ok := a.sessionPicker.SelectedItem().(sessionItem) + case pickerHelp: + item, ok := a.helpPicker.SelectedItem().(selectionItem) a.closePicker() if !ok { return a, nil } - if err := a.activateSessionByID(item.Summary.ID); err != nil { + return a, a.runSlashCommandSelection(item.id) + case pickerSession: + a.closePicker() + if err := a.activateSelectedSession(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() - a.appendActivity("system", "Failed to switch session", err.Error(), true) - } - return a, nil - case pickerHelp: - item, ok := a.helpPicker.SelectedItem().(selectionItem) - a.closePicker() - if !ok { + a.appendActivity("session", "Failed to activate session", err.Error(), true) return a, nil } - return a, a.runSlashCommandSelection(item.id) + a.rebuildTranscript() + a.state.StatusText = statusReady + return a, nil } } @@ -1053,7 +1054,7 @@ func runtimeEventToolResultHandler(a *App, event agentruntime.RuntimeEvent) bool } a.activeMessages = append(a.activeMessages, providertypes.Message{ Role: roleTool, - Content: payload.Content, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(payload.Content)}, IsError: payload.IsError, }) if payload.IsError { @@ -1100,8 +1101,11 @@ func runtimeEventAgentDoneHandler(a *App, event agentruntime.RuntimeEvent) bool if strings.TrimSpace(a.state.ExecutionError) == "" { a.state.StatusText = statusReady } - if payload, ok := event.Payload.(providertypes.Message); ok && strings.TrimSpace(payload.Content) != "" && !a.lastAssistantMatches(payload.Content) { - a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleAssistant, Content: payload.Content}) + if payload, ok := event.Payload.(providertypes.Message); ok { + content := renderMessagePartsForDisplay(payload.Parts) + if strings.TrimSpace(content) != "" && !a.lastAssistantMatches(content) { + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart(content)}}) + } return true } return false @@ -1258,12 +1262,13 @@ func (a *App) appendAssistantChunk(chunk string) { } if !a.state.StreamingReply || len(a.activeMessages) == 0 || a.activeMessages[len(a.activeMessages)-1].Role != roleAssistant { - a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleAssistant, Content: chunk}) + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart(chunk)}}) a.state.StreamingReply = true return } - a.activeMessages[len(a.activeMessages)-1].Content += chunk + content := renderMessagePartsForDisplay(a.activeMessages[len(a.activeMessages)-1].Parts) + a.activeMessages[len(a.activeMessages)-1].Parts = []providertypes.ContentPart{providertypes.NewTextPart(content + chunk)} } func (a *App) appendInlineMessage(role string, message string) { @@ -1272,7 +1277,7 @@ func (a *App) appendInlineMessage(role string, message string) { return } - a.activeMessages = append(a.activeMessages, providertypes.Message{Role: role, Content: content}) + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: role, Parts: []providertypes.ContentPart{providertypes.NewTextPart(content)}}) } func (a *App) appendActivity(kind string, title string, detail string, isError bool) { @@ -1324,7 +1329,7 @@ func (a *App) lastAssistantMatches(content string) bool { } last := a.activeMessages[len(a.activeMessages)-1] - return last.Role == roleAssistant && strings.TrimSpace(last.Content) == strings.TrimSpace(content) + return last.Role == roleAssistant && strings.TrimSpace(renderMessagePartsForDisplay(last.Parts)) == strings.TrimSpace(content) } func (a *App) handleViewportKeys(vp *viewport.Model, msg tea.KeyMsg) { @@ -1896,7 +1901,7 @@ func runAgent(runtime agentruntime.Runtime, runID string, sessionID string, work agentruntime.UserInput{ SessionID: sessionID, RunID: strings.TrimSpace(runID), - Content: content, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(content)}, Workdir: workdir, }, func(err error) tea.Msg { return runFinishedMsg{Err: err} }, diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index d4df5983..8ff0d3a5 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -159,6 +159,9 @@ func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, w return agentsession.NewWithWorkdir("draft", workdir), nil } +func messageText(message providertypes.Message) string { + return renderMessagePartsForDisplay(message.Parts) +} func (s *snapshotRuntime) GetSessionContext(ctx context.Context, sessionID string) (any, error) { return s.sessionContext, nil } @@ -957,7 +960,7 @@ func TestRuntimeEventToolResultHandlerUpdatesMessages(t *testing.T) { t.Fatalf("expected handler to return true") } last := app.activeMessages[len(app.activeMessages)-1] - if last.Role != roleTool || last.Content != "ok" { + if last.Role != roleTool || messageText(last) != "ok" { t.Fatalf("unexpected tool message: %#v", last) } } @@ -981,7 +984,7 @@ func TestRuntimeEventToolResultHandlerError(t *testing.T) { func TestRuntimeEventAgentDoneHandlerAppendsMessage(t *testing.T) { app, _ := newTestApp(t) - payload := providertypes.Message{Role: roleAssistant, Content: "done"} + payload := providertypes.Message{Role: roleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}} handled := runtimeEventAgentDoneHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) if !handled { t.Fatalf("expected handler to return true") @@ -1592,6 +1595,52 @@ func TestUpdateSendWithUnsupportedImageInput(t *testing.T) { if app.state.StatusText != "Model does not support images" { t.Fatalf("unexpected status text: %q", app.state.StatusText) } + if app.input.Value() != "hello" { + t.Fatalf("expected input to be preserved when send is blocked, got %q", app.input.Value()) + } + if app.state.InputText != "hello" { + t.Fatalf("expected state input text to be preserved, got %q", app.state.InputText) + } +} + +func TestUpdateSendWithImageAttachmentsWithoutSessionAssets(t *testing.T) { + app, _ := newTestApp(t) + app.pendingImageAttachments = []pendingImageAttachment{ + {Name: "a.png", MimeType: "image/png", Path: "/tmp/a.png", Size: 1}, + } + app.providerSvc = stubProviderService{ + providers: []configstate.ProviderOption{{ID: app.state.CurrentProvider, Name: app.state.CurrentProvider}}, + models: []providertypes.ModelDescriptor{{ + ID: app.state.CurrentModel, + Name: app.state.CurrentModel, + CapabilityHints: providertypes.ModelCapabilityHints{ + ImageInput: providertypes.ModelCapabilityStateSupported, + }, + }}, + } + app.input.SetValue("hello") + app.state.InputText = "hello" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + _ = cmd() + } + app = model.(App) + if app.state.IsAgentRunning { + t.Fatalf("expected send to be blocked when session assets are unavailable") + } + if app.hasImageAttachments() { + t.Fatalf("expected pending image attachments to be cleared when storage is unavailable") + } + if app.state.StatusText != "Image attachments need session asset support" { + t.Fatalf("unexpected status text: %q", app.state.StatusText) + } + if app.input.Value() != "hello" { + t.Fatalf("expected input to be preserved when send is blocked, got %q", app.input.Value()) + } + if app.state.InputText != "hello" { + t.Fatalf("expected state input text to be preserved, got %q", app.state.InputText) + } } func TestUpdatePickerSessionEnterActivatesSelectedSession(t *testing.T) { @@ -1607,7 +1656,7 @@ func TestUpdatePickerSessionEnterActivatesSelectedSession(t *testing.T) { Title: "Two", Workdir: app.state.CurrentWorkdir, Messages: []providertypes.Message{ - {Role: roleUser, Content: "hello"}, + {Role: roleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, }, }, } @@ -1782,7 +1831,7 @@ func TestAppendAssistantAndInlineMessage(t *testing.T) { app, _ := newTestApp(t) app.appendAssistantChunk("hi") app.appendAssistantChunk(" there") - if len(app.activeMessages) == 0 || !strings.Contains(app.activeMessages[len(app.activeMessages)-1].Content, "there") { + if len(app.activeMessages) == 0 || !strings.Contains(messageText(app.activeMessages[len(app.activeMessages)-1]), "there") { t.Fatalf("expected assistant chunk to append") } app.appendInlineMessage(roleSystem, " note ") @@ -2141,8 +2190,8 @@ func TestHandleMemoCommand(t *testing.T) { t.Fatal("expected at least one inline message") } last := msgs[len(msgs)-1] - if !strings.Contains(last.Content, "No memos stored yet") { - t.Errorf("expected 'no memos' message, got: %s", last.Content) + if !strings.Contains(messageText(last), "No memos stored yet") { + t.Errorf("expected 'no memos' message, got: %s", messageText(last)) } }) @@ -2153,11 +2202,11 @@ func TestHandleMemoCommand(t *testing.T) { app.handleMemoCommand() msgs := app.activeMessages last := msgs[len(msgs)-1] - if !strings.Contains(last.Content, "1 memo(s)") { - t.Errorf("expected memo count, got: %s", last.Content) + if !strings.Contains(messageText(last), "1 memo(s)") { + t.Errorf("expected memo count, got: %s", messageText(last)) } - if !strings.Contains(last.Content, "test entry") { - t.Errorf("expected entry title, got: %s", last.Content) + if !strings.Contains(messageText(last), "test entry") { + t.Errorf("expected entry title, got: %s", messageText(last)) } }) @@ -2172,8 +2221,8 @@ func TestHandleMemoCommand(t *testing.T) { t.Fatal("expected at least one inline message") } last := msgs[len(msgs)-1] - if !strings.Contains(last.Content, "not enabled") { - t.Errorf("expected 'not enabled' message, got: %s", last.Content) + if !strings.Contains(messageText(last), "not enabled") { + t.Errorf("expected 'not enabled' message, got: %s", messageText(last)) } }) } @@ -2189,8 +2238,8 @@ func TestHandleRememberCommand(t *testing.T) { } msgs := app.activeMessages last := msgs[len(msgs)-1] - if !strings.Contains(last.Content, "Memo saved") { - t.Errorf("expected saved confirmation, got: %s", last.Content) + if !strings.Contains(messageText(last), "Memo saved") { + t.Errorf("expected saved confirmation, got: %s", messageText(last)) } // Verify the entry was actually saved entries, _ := app.memoSvc.List(context.Background()) @@ -2207,8 +2256,8 @@ func TestHandleRememberCommand(t *testing.T) { app.handleRememberCommand("") msgs := app.activeMessages last := msgs[len(msgs)-1] - if !strings.Contains(last.Content, "Usage") { - t.Errorf("expected usage message, got: %s", last.Content) + if !strings.Contains(messageText(last), "Usage") { + t.Errorf("expected usage message, got: %s", messageText(last)) } }) @@ -2217,8 +2266,8 @@ func TestHandleRememberCommand(t *testing.T) { app.handleRememberCommand(" ") msgs := app.activeMessages last := msgs[len(msgs)-1] - if !strings.Contains(last.Content, "Usage") { - t.Errorf("expected usage message, got: %s", last.Content) + if !strings.Contains(messageText(last), "Usage") { + t.Errorf("expected usage message, got: %s", messageText(last)) } }) @@ -2227,8 +2276,8 @@ func TestHandleRememberCommand(t *testing.T) { app.handleRememberCommand("something") msgs := app.activeMessages last := msgs[len(msgs)-1] - if !strings.Contains(last.Content, "not enabled") { - t.Errorf("expected 'not enabled' message, got: %s", last.Content) + if !strings.Contains(messageText(last), "not enabled") { + t.Errorf("expected 'not enabled' message, got: %s", messageText(last)) } }) } @@ -2244,8 +2293,8 @@ func TestHandleForgetCommand(t *testing.T) { app.handleForgetCommand("remove") msgs := app.activeMessages last := msgs[len(msgs)-1] - if !strings.Contains(last.Content, "Removed 1 memo") { - t.Errorf("expected removal confirmation, got: %s", last.Content) + if !strings.Contains(messageText(last), "Removed 1 memo") { + t.Errorf("expected removal confirmation, got: %s", messageText(last)) } // Verify only one was removed entries, _ := app.memoSvc.List(context.Background()) @@ -2262,8 +2311,8 @@ func TestHandleForgetCommand(t *testing.T) { app.handleForgetCommand("nonexistent") msgs := app.activeMessages last := msgs[len(msgs)-1] - if !strings.Contains(last.Content, "No memos matching") { - t.Errorf("expected no match message, got: %s", last.Content) + if !strings.Contains(messageText(last), "No memos matching") { + t.Errorf("expected no match message, got: %s", messageText(last)) } }) @@ -2272,8 +2321,8 @@ func TestHandleForgetCommand(t *testing.T) { app.handleForgetCommand("") msgs := app.activeMessages last := msgs[len(msgs)-1] - if !strings.Contains(last.Content, "Usage") { - t.Errorf("expected usage message, got: %s", last.Content) + if !strings.Contains(messageText(last), "Usage") { + t.Errorf("expected usage message, got: %s", messageText(last)) } }) @@ -2282,8 +2331,8 @@ func TestHandleForgetCommand(t *testing.T) { app.handleForgetCommand("something") msgs := app.activeMessages last := msgs[len(msgs)-1] - if !strings.Contains(last.Content, "not enabled") { - t.Errorf("expected 'not enabled' message, got: %s", last.Content) + if !strings.Contains(messageText(last), "not enabled") { + t.Errorf("expected 'not enabled' message, got: %s", messageText(last)) } }) } @@ -2316,7 +2365,7 @@ func TestActivateSelectedSession(t *testing.T) { Title: "Active Session", Workdir: app.state.CurrentWorkdir, Messages: []providertypes.Message{ - {Role: roleUser, Content: "hello"}, + {Role: roleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, }, }, } @@ -2857,7 +2906,7 @@ func TestUpdateKeyToggleQuitCancelAndPickerClose(t *testing.T) { } func TestUpdatePickerEnterInvalidSelectionsAndSessionActivationError(t *testing.T) { - app, _ := newTestApp(t) + app, runtime := newTestApp(t) app.providerPicker.SetItems([]list.Item{sessionItem{Summary: agentsession.Summary{ID: "s1"}}}) app.openPicker(pickerProvider, statusChooseProvider, &app.providerPicker, "") @@ -2876,6 +2925,7 @@ func TestUpdatePickerEnterInvalidSelectionsAndSessionActivationError(t *testing. } app.sessionPicker.SetItems([]list.Item{sessionItem{Summary: agentsession.Summary{ID: "missing", Title: "missing"}}}) + runtime.loadSessionErr = errors.New("load failed") app.openPicker(pickerSession, statusChooseSession, &app.sessionPicker, "") model, cmd = app.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}) app = model.(App) diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 2eb3b516..83fa314f 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -316,11 +316,11 @@ func (a App) renderPanel(title string, subtitle string, body string, width int, func (a App) renderMessageBlockWithCopy(message providertypes.Message, width int, startCopyID int) (string, []copyCodeButtonBinding) { switch message.Role { case roleEvent: - return a.styles.inlineNotice.Width(width).Render(" > " + wrapPlain(message.Content, max(16, width-6))), nil + return a.styles.inlineNotice.Width(width).Render(" > " + wrapPlain(renderMessagePartsForDisplay(message.Parts), max(16, width-6))), nil case roleError: - return a.styles.inlineError.Width(width).Render(" ! " + wrapPlain(message.Content, max(16, width-6))), nil + return a.styles.inlineError.Width(width).Render(" ! " + wrapPlain(renderMessagePartsForDisplay(message.Parts), max(16, width-6))), nil case roleSystem: - return a.styles.inlineSystem.Width(width).Render(" - " + wrapPlain(message.Content, max(16, width-6))), nil + return a.styles.inlineSystem.Width(width).Render(" - " + wrapPlain(renderMessagePartsForDisplay(message.Parts), max(16, width-6))), nil } maxMessageWidth := tuiutils.Clamp(int(float64(width)*0.84), 24, width) @@ -338,7 +338,7 @@ func (a App) renderMessageBlockWithCopy(message providertypes.Message, width int return "", nil } - content := strings.TrimSpace(message.Content) + content := strings.TrimSpace(renderMessagePartsForDisplay(message.Parts)) if content == "" && len(message.ToolCalls) > 0 { names := make([]string, 0, len(message.ToolCalls)) for _, call := range message.ToolCalls { diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go index b5b64129..0c965689 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -182,8 +182,8 @@ func TestRenderUserMessageKeepsTagAndBodyRightAligned(t *testing.T) { app, _ := newTestApp(t) block, _ := app.renderMessageBlockWithCopy(providertypes.Message{ - Role: roleUser, - Content: "hello right aligned", + Role: roleUser, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello right aligned")}, }, 72, 1) plain := copyCodeANSIPattern.ReplaceAllString(block, "") @@ -452,12 +452,18 @@ func TestRenderMessageContentWithCopyCodeFallbackAndEmptySegments(t *testing.T) func TestRenderMessageBlockWithCopyExtraBranches(t *testing.T) { app, _ := newTestApp(t) - eventBlock, _ := app.renderMessageBlockWithCopy(providertypes.Message{Role: roleEvent, Content: "event"}, 50, 1) + eventBlock, _ := app.renderMessageBlockWithCopy(providertypes.Message{ + Role: roleEvent, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("event")}, + }, 50, 1) if !strings.Contains(eventBlock, "event") { t.Fatalf("expected event block") } - toolBlock, bindings := app.renderMessageBlockWithCopy(providertypes.Message{Role: roleTool, Content: "tool"}, 50, 1) + toolBlock, bindings := app.renderMessageBlockWithCopy(providertypes.Message{ + Role: roleTool, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool")}, + }, 50, 1) if toolBlock != "" || bindings != nil { t.Fatalf("expected tool role to be skipped") } @@ -474,8 +480,8 @@ func TestRenderMessageBlockWithCopyExtraBranches(t *testing.T) { } userBlock, _ := app.renderMessageBlockWithCopy(providertypes.Message{ - Role: roleUser, - Content: "hello", + Role: roleUser, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}, }, 10, 1) if strings.TrimSpace(userBlock) == "" { t.Fatalf("expected user message block") diff --git a/internal/tui/services/parts_render_test.go b/internal/tui/services/parts_render_test.go new file mode 100644 index 00000000..6a21f6b4 --- /dev/null +++ b/internal/tui/services/parts_render_test.go @@ -0,0 +1,20 @@ +package services + +import ( + "strings" + + providertypes "neo-code/internal/provider/types" +) + +func renderPartsForTest(parts []providertypes.ContentPart) string { + var builder strings.Builder + for _, part := range parts { + switch part.Kind { + case providertypes.ContentPartText: + builder.WriteString(part.Text) + case providertypes.ContentPartImage: + builder.WriteString("[Image]") + } + } + return builder.String() +} diff --git a/internal/tui/services/services_test.go b/internal/tui/services/services_test.go index 39afd6f3..42b5eec4 100644 --- a/internal/tui/services/services_test.go +++ b/internal/tui/services/services_test.go @@ -95,9 +95,9 @@ func TestListenForRuntimeEventCmd(t *testing.T) { func TestRunAgentCmd(t *testing.T) { runner := &stubRunner{err: errors.New("boom")} - input := agentruntime.UserInput{SessionID: "s1", Content: "hello", Workdir: "D:/"} + input := agentruntime.UserInput{SessionID: "s1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}, Workdir: "D:/"} msg := RunAgentCmd(runner, input, func(err error) tea.Msg { return err })() - if runner.lastInput.SessionID != "s1" || runner.lastInput.Content != "hello" { + if runner.lastInput.SessionID != "s1" || renderPartsForTest(runner.lastInput.Parts) != "hello" { t.Fatalf("unexpected runner input: %+v", runner.lastInput) } if err, ok := msg.(error); !ok || err == nil || err.Error() != "boom" {