From 64e6bc1247163e92d4be4717ac98752cf658d172 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 16 Apr 2026 01:05:01 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(runtime\provider):=E5=88=9D=E6=AD=A5?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=A4=9A=E6=A8=A1=E6=80=81=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/bootstrap_test.go | 22 +- internal/context/builder_test.go | 194 +++++------ internal/context/compact/helpers.go | 2 +- internal/context/compact/planner_test.go | 28 +- internal/context/compact/runner.go | 4 +- internal/context/compact/runner_test.go | 173 +++++----- internal/context/compact/transcript_store.go | 2 +- .../context/compact/transcript_store_test.go | 4 +- internal/context/compact_prompt.go | 2 +- internal/context/compact_prompt_test.go | 6 +- internal/context/internalcompact/messages.go | 2 +- .../context/internalcompact/messages_test.go | 10 +- internal/context/microcompact.go | 4 +- internal/context/microcompact_test.go | 164 ++++----- internal/context/projection.go | 6 +- internal/context/projection_test.go | 88 ++--- internal/context/source_skills_test.go | 4 +- internal/memo/auto_extractor_test.go | 45 ++- internal/memo/extractor.go | 2 +- internal/memo/extractor_test.go | 28 +- internal/memo/llm_extractor.go | 2 +- internal/memo/llm_extractor_test.go | 74 +++-- internal/memo/message_clone.go | 1 + internal/memo/message_clone_test.go | 4 +- internal/provider/generate_test.go | 2 +- .../chatcompletions/provider_test.go | 66 +++- .../openaicompat/chatcompletions/request.go | 57 +++- .../openaicompat/chatcompletions/types.go | 14 +- .../openaicompat/driver_internal_test.go | 2 +- .../openaicompat/openaicompat_test.go | 58 ++-- internal/provider/streaming/accumulator.go | 4 +- internal/provider/types/content_part.go | 138 ++++++++ internal/provider/types/content_part_test.go | 115 +++++++ internal/provider/types/message.go | 12 +- internal/provider/types/types_test.go | 4 +- internal/runtime/compact_generator.go | 6 +- internal/runtime/compact_generator_test.go | 31 +- internal/runtime/permission_test.go | 16 +- internal/runtime/run.go | 32 +- internal/runtime/runtime.go | 2 +- internal/runtime/runtime_gap_coverage_test.go | 10 +- .../runtime/runtime_internal_helpers_test.go | 18 +- internal/runtime/runtime_progress_test.go | 16 +- .../runtime_remaining_branches_test.go | 4 +- internal/runtime/runtime_test.go | 313 +++++++++--------- internal/runtime/session_mutation.go | 11 +- internal/runtime/skills_test.go | 2 +- internal/session/skill_activation_test.go | 4 +- internal/session/store_test.go | 43 ++- internal/session/task_state.go | 2 +- internal/tools/format.go | 4 +- internal/tools/format_test.go | 2 +- internal/tui/core/app/update.go | 34 +- internal/tui/core/app/update_test.go | 60 ++-- internal/tui/core/app/view.go | 8 +- internal/tui/core/app/view_test.go | 4 +- internal/tui/services/services_test.go | 4 +- 57 files changed, 1186 insertions(+), 783 deletions(-) create mode 100644 internal/provider/types/content_part.go create mode 100644 internal/provider/types/content_part_test.go diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index 4e4876d5..a55f2b00 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 || providertypes.ExtractTextForProjection(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 || providertypes.ExtractTextForProjection(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/context/builder_test.go b/internal/context/builder_test.go index b17921e7..ce98ec50 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -36,7 +36,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()), } @@ -94,7 +94,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 { @@ -117,7 +117,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,8 +148,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))}, }) } @@ -169,7 +169,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 providertypes.ExtractTextForProjection(got.Messages[0].Parts) != "u-2" { t.Fatalf("expected oldest messages to be trimmed, got first message %+v", got.Messages[0]) } } @@ -199,30 +199,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{ @@ -235,14 +235,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 providertypes.ExtractTextForProjection(got.Messages[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected builder output to clear older tool result, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got.Messages[4].Parts) != "recent bash result" { + t.Fatalf("expected recent tool result to stay visible, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got.Messages[6].Parts) != "latest webfetch result" { + t.Fatalf("expected latest tool result to stay visible, got %q", providertypes.ExtractTextForProjection(got.Messages[6].Parts)) } } @@ -256,29 +256,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 providertypes.ExtractTextForProjection(got.Messages[2].Parts) != "old read result" { + t.Fatalf("expected old tool result to remain visible without task state, got %q", providertypes.ExtractTextForProjection(got.Messages[2].Parts)) } } @@ -292,30 +292,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{ @@ -348,37 +348,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 providertypes.ExtractTextForProjection(got.Messages[2].Parts) != "old custom result" { + t.Fatalf("expected preserved tool result to remain, got %q", providertypes.ExtractTextForProjection(got.Messages[2].Parts)) } } @@ -390,37 +390,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 providertypes.ExtractTextForProjection(got.Messages[2].Parts) != "old custom result" { + t.Fatalf("expected preserved tool result to remain, got %q", providertypes.ExtractTextForProjection(got.Messages[2].Parts)) } } @@ -429,7 +429,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{ @@ -438,9 +438,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) @@ -470,25 +470,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 || providertypes.ExtractTextForProjection(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 { @@ -503,7 +503,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{ @@ -512,23 +512,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 || providertypes.ExtractTextForProjection(trimmed[0].Parts) != providertypes.ExtractTextForProjection(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 { @@ -548,8 +548,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) { @@ -564,7 +564,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{ @@ -573,8 +573,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 }(), @@ -591,7 +591,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{ @@ -600,14 +600,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 providertypes.ExtractTextForProjection(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" { @@ -636,7 +636,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}, } @@ -656,7 +656,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}, } @@ -676,7 +676,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}, } @@ -696,7 +696,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}, } @@ -720,7 +720,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) @@ -738,7 +738,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) @@ -758,7 +758,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"}, }, } @@ -767,8 +767,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 := providertypes.ExtractTextForProjection(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) @@ -784,7 +785,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{ @@ -794,7 +795,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"}, }, }, @@ -810,10 +811,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 := providertypes.ExtractTextForProjection(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..cac32c38 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(providertypes.ExtractTextForProjection(message.Parts)) total += utf8.RuneCountInString(message.ToolCallID) for _, call := range message.ToolCalls { total += utf8.RuneCountInString(call.ID) diff --git a/internal/context/compact/planner_test.go b/internal/context/compact/planner_test.go index bcce24fd..ee368e15 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 || providertypes.ExtractTextForProjection(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..57f0f281 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(providertypes.ExtractTextForProjection(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..69d59bcb 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 := providertypes.ExtractTextForProjection(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(providertypes.ExtractTextForProjection(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 || providertypes.ExtractTextForProjection(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..dc569cee 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: providertypes.ExtractTextForProjection(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..b563af8a 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) @@ -113,7 +113,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..7c163443 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(providertypes.ExtractTextForProjection(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..4dd3de44 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")}}, }, }) diff --git a/internal/context/internalcompact/messages.go b/internal/context/internalcompact/messages.go index 3574707b..21ff4801 100644 --- a/internal/context/internalcompact/messages.go +++ b/internal/context/internalcompact/messages.go @@ -75,7 +75,7 @@ func RetainedStartForKeepRecentMessages(spans []MessageSpan, keepMessages int) i // 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 && strings.TrimSpace(providertypes.ExtractTextForProjection(messages[index].Parts)) != "" { return index } } diff --git a/internal/context/internalcompact/messages_test.go b/internal/context/internalcompact/messages_test.go index b561ccec..a8ad9815 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) diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index 4ce51368..3393b4ef 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(providertypes.ExtractTextForProjection(message.Parts)) return content != "" && content != microCompactClearedMessage } diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go index 49b0e4de..899793f4 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 providertypes.ExtractTextForProjection(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected oldest compactable tool result to be cleared, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got[4].Parts) != "recent bash result" { + t.Fatalf("expected recent compactable tool result to be retained, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got[6].Parts) != "latest webfetch result" { + t.Fatalf("expected latest compactable tool result to be retained, got %q", providertypes.ExtractTextForProjection(got[6].Parts)) } - if messages[2].Content != "old read result" { - t.Fatalf("expected original slice to remain unchanged, got %q", messages[2].Content) + if providertypes.ExtractTextForProjection(messages[2].Parts) != "old read result" { + t.Fatalf("expected original slice to remain unchanged, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected old tool result before protected tail to be cleared, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got[4].Parts) != "recent read result" { + t.Fatalf("expected recent tool result before protected tail to remain, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got[6].Parts) != "recent bash result" { + t.Fatalf("expected second recent tool result before protected tail to remain, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got[9].Parts) != "tail bash result" { + t.Fatalf("expected protected tail tool result to remain, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got[1].Parts) != "custom result" { + t.Fatalf("expected preserved tool result to remain, got %q", providertypes.ExtractTextForProjection(got[1].Parts)) } - if got[3].Content != "edit failed" { - t.Fatalf("expected error tool result to remain, got %q", got[3].Content) + if providertypes.ExtractTextForProjection(got[3].Parts) != "edit failed" { + t.Fatalf("expected error tool result to remain, got %q", providertypes.ExtractTextForProjection(got[3].Parts)) } - if got[4].Content != "orphan result" { - t.Fatalf("expected orphan tool result to remain, got %q", got[4].Content) + if providertypes.ExtractTextForProjection(got[4].Parts) != "orphan result" { + t.Fatalf("expected orphan tool result to remain, got %q", providertypes.ExtractTextForProjection(got[4].Parts)) } - if got[6].Content != microCompactClearedMessage { - t.Fatalf("expected already cleared content to remain unchanged, got %q", got[6].Content) + if providertypes.ExtractTextForProjection(got[6].Parts) != microCompactClearedMessage { + t.Fatalf("expected already cleared content to remain unchanged, got %q", providertypes.ExtractTextForProjection(got[6].Parts)) } - if got[8].Content != "" { - t.Fatalf("expected empty tool result to remain empty, got %q", got[8].Content) + if providertypes.ExtractTextForProjection(got[8].Parts) != "" { + t.Fatalf("expected empty tool result to remain empty, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected default compactable tool result to be cleared, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got[3].Parts) != "custom result" { + t.Fatalf("expected preserved tool result in mixed span to remain, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected new tool result to be compacted by default, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected oldest valid tool result to be cleared, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got[4].Parts) != "middle grep result" { + t.Fatalf("expected middle valid tool result to remain, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got[6].Parts) != "near edit result" { + t.Fatalf("expected nearer valid tool result to remain, got %q", providertypes.ExtractTextForProjection(got[6].Parts)) } - if got[8].Content != "" { - t.Fatalf("expected error/empty tool result to remain unchanged, got %q", got[8].Content) + if providertypes.ExtractTextForProjection(got[8].Parts) != "" { + t.Fatalf("expected error/empty tool result to remain unchanged, got %q", providertypes.ExtractTextForProjection(got[8].Parts)) } - if got[10].Content != "" { - t.Fatalf("expected empty recent tool result to remain unchanged, got %q", got[10].Content) + if providertypes.ExtractTextForProjection(got[10].Parts) != "" { + t.Fatalf("expected empty recent tool result to remain unchanged, got %q", providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(got[0].Parts) != "orphan result" { + t.Fatalf("expected orphan tool result to remain, got %q", providertypes.ExtractTextForProjection(got[0].Parts)) } } diff --git a/internal/context/projection.go b/internal/context/projection.go index 85e7ecd0..2e7c8bdd 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(providertypes.ExtractTextForProjection(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(providertypes.ExtractTextForProjection(message.Parts)))} } return messages } diff --git a/internal/context/projection_test.go b/internal/context/projection_test.go index 5b8f428a..3d3d0a56 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 providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(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(providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(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(providertypes.ExtractTextForProjection(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(providertypes.ExtractTextForProjection(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(providertypes.ExtractTextForProjection(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(providertypes.ExtractTextForProjection(recent[1].Parts), "content_excerpt:") { t.Fatalf("expected tool payload excerpt marker, got %+v", recent[1]) } - if recent[2].Content != "latest-user" { + if providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(recent[0].Parts) != "old-user" || providertypes.ExtractTextForProjection(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..e770bbee 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 := providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(messages[len(messages)-1].Parts) { case "first": firstStarted <- struct{}{} <-releaseFirst case "second": secondStarted <- struct{}{} } - last := messages[len(messages)-1].Content + last := providertypes.ExtractTextForProjection(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..6a98be0b 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 = providertypes.ExtractTextForProjection(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..9ce6752b 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 && strings.TrimSpace(providertypes.ExtractTextForProjection(message.Parts)) != "" { return true } } diff --git a/internal/memo/llm_extractor_test.go b/internal/memo/llm_extractor_test.go index 10bc7864..f529292e 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 providertypes.ExtractTextForProjection(generator.messages[0].Parts) != "user-c" || + providertypes.ExtractTextForProjection(generator.messages[9].Parts) != "user-l" { + t.Fatalf("unexpected recent window: first=%q last=%q", + providertypes.ExtractTextForProjection(generator.messages[0].Parts), + providertypes.ExtractTextForProjection(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 := providertypes.ExtractTextForProjection(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 := providertypes.ExtractTextForProjection(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) 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..59f60904 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 || providertypes.ExtractTextForProjection(cloned.Parts) != "hi" { t.Fatalf("unexpected cloned message %+v", cloned) } if cloned.ToolCalls != nil { 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..b4dd0484 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,69 @@ 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{ + 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 image part payload") { + t.Fatalf("expected unsupported image error, got %v", err) + } }) } @@ -495,7 +539,7 @@ func TestGenerateVariants(t *testing.T) { } if err := mustProvider(t).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")}}}, 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) @@ -507,7 +551,7 @@ func TestGenerateVariants(t *testing.T) { 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"}}, + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, }, nil); err == nil || !strings.Contains(err.Error(), "build request") { t.Fatalf("expected build request error, got %v", err) } @@ -519,7 +563,7 @@ func TestGenerateVariants(t *testing.T) { })}, } if err := sendErr.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); err == nil || !strings.Contains(err.Error(), "send request") { t.Fatalf("expected send request error, got %v", err) } @@ -539,7 +583,7 @@ func TestGenerateVariants(t *testing.T) { t.Fatalf("New() error = %v", err) } if 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); err == nil || !strings.Contains(err.Error(), "invalid api key") { t.Fatalf("expected parsed provider error, got %v", err) } @@ -580,7 +624,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..76eeacbd 100644 --- a/internal/provider/openaicompat/chatcompletions/request.go +++ b/internal/provider/openaicompat/chatcompletions/request.go @@ -38,7 +38,11 @@ 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 { @@ -60,13 +64,58 @@ func BuildRequest(cfg provider.RuntimeConfig, req providertypes.GenerateRequest) } // ToOpenAIMessage 将通用 Message 转换为 OpenAI 协议消息格式。 -func ToOpenAIMessage(message providertypes.Message) Message { +func ToOpenAIMessage(message providertypes.Message) (Message, error) { 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 text string + for _, part := range message.Parts { + if part.Kind == providertypes.ContentPartText { + text += part.Text + } + } + if text != "" { + out.Content = text + } + } else { + var contentParts []MessageContentPart + for _, part := range message.Parts { + if part.Kind == providertypes.ContentPartText { + contentParts = append(contentParts, MessageContentPart{ + Type: "text", + Text: part.Text, + }) + } else if part.Kind == providertypes.ContentPartImage { + if part.Image != nil && part.Image.SourceType == providertypes.ImageSourceRemote { + contentParts = append(contentParts, MessageContentPart{ + Type: "image_url", + ImageURL: &ImageURL{ + URL: part.Image.URL, + }, + }) + } else if part.Image != nil && part.Image.SourceType == providertypes.ImageSourceSessionAsset { + return Message{}, errors.New("session_asset image is not supported in this phase") + } else { + 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 +130,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/types/content_part.go b/internal/provider/types/content_part.go new file mode 100644 index 00000000..4ab7acec --- /dev/null +++ b/internal/provider/types/content_part.go @@ -0,0 +1,138 @@ +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 { + if part.Kind == ContentPartText { + if part.Image != nil { + return errors.New("text part cannot contain image payload") + } + } else if part.Kind == ContentPartImage { + if part.Image == nil { + return errors.New("image part must contain image payload") + } + if part.Image.SourceType == ImageSourceRemote && part.Image.URL == "" { + return errors.New("remote image part must contain url") + } + if part.Image.SourceType == ImageSourceSessionAsset && (part.Image.Asset == nil || part.Image.Asset.ID == "") { + return errors.New("session asset image part must contain asset ID") + } + } else { + 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 +} + +// ExtractTextForProjection extracts pure text from parts for legacy compatibility. +// TODO(Phase 4): Migrate to proper Part rendering. +func ExtractTextForProjection(parts []ContentPart) string { + var builder strings.Builder + for _, part := range parts { + if part.Kind == ContentPartText { + builder.WriteString(part.Text) + } else if part.Kind == ContentPartImage { + builder.WriteString("[Image]") + } + } + return builder.String() +} diff --git a/internal/provider/types/content_part_test.go b/internal/provider/types/content_part_test.go new file mode 100644 index 00000000..b2ee979b --- /dev/null +++ b/internal/provider/types/content_part_test.go @@ -0,0 +1,115 @@ +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: "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: "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..43e0757e 100644 --- a/internal/provider/types/message.go +++ b/internal/provider/types/message.go @@ -15,13 +15,23 @@ 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 { + return len(m.Parts) == 0 && len(m.ToolCalls) == 0 +} + +// 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/types_test.go b/internal/provider/types/types_test.go index 98292736..bd946395 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 || ExtractTextForProjection(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..2213d860 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,7 @@ func (g *compactSummaryGenerator) Generate( return contextcompact.SummaryOutput{}, errors.New("runtime: compact summary response must not contain tool calls") } - return parseCompactSummaryOutput(message.Content) + return parseCompactSummaryOutput(providertypes.ExtractTextForProjection(message.Parts)) } // parseCompactSummaryOutput 解析 compact 生成器返回的 JSON 响应,容忍数组字段被返回为字符串。 diff --git a/internal/runtime/compact_generator_test.go b/internal/runtime/compact_generator_test.go index 2472dccf..726158b1 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 := providertypes.ExtractTextForProjection(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/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 d698b420..71407c15 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 } @@ -92,7 +91,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { } } - 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) } @@ -108,7 +108,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) } @@ -430,3 +430,27 @@ 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 strings.TrimSpace(providertypes.ExtractTextForProjection(parts)) == "" { + return errors.New("runtime: input content is empty") + } + return nil +} + +// 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" +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index ca619ffc..b7002848 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..58806fd6 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 providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(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..004bde09 100644 --- a/internal/runtime/runtime_internal_helpers_test.go +++ b/internal/runtime/runtime_internal_helpers_test.go @@ -200,8 +200,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 providertypes.ExtractTextForProjection(msg.Parts) != "" { + t.Fatalf("expected metadata-only success result to keep empty content, got %q", providertypes.ExtractTextForProjection(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 +228,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 providertypes.ExtractTextForProjection(msg.Parts) != "ok" { + t.Fatalf("expected empty success result to be normalized to ok, got %q", providertypes.ExtractTextForProjection(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 +259,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 providertypes.ExtractTextForProjection(msg.Parts) != "ok" { + t.Fatalf("expected tool_name-only metadata success to normalize content to ok, got %q", providertypes.ExtractTextForProjection(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 +391,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 providertypes.ExtractTextForProjection(state.session.Messages[0].Parts) != toolErr.Error() { + t.Fatalf("expected tool error content fallback, got %q", providertypes.ExtractTextForProjection(state.session.Messages[0].Parts)) } } @@ -458,7 +458,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 39b145ae..252b273a 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -225,9 +225,9 @@ func (p *scriptedProvider) Generate(ctx context.Context, req providertypes.Gener return ctx.Err() } } - if response.Message.Content != "" { + if providertypes.ExtractTextForProjection(response.Message.Parts) != "" { select { - case events <- providertypes.NewTextDeltaStreamEvent(response.Message.Content): + case events <- providertypes.NewTextDeltaStreamEvent(providertypes.ExtractTextForProjection(response.Message.Parts)): case <-ctx.Done(): return ctx.Err() } @@ -438,7 +438,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 "), @@ -450,7 +450,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 }, @@ -470,14 +470,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 || providertypes.ExtractTextForProjection(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{ @@ -524,10 +524,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(providertypes.ExtractTextForProjection(message.Parts), "tool result") && + strings.Contains(providertypes.ExtractTextForProjection(message.Parts), "tool: filesystem_edit") && + strings.Contains(providertypes.ExtractTextForProjection(message.Parts), "status: ok") && + strings.Contains(providertypes.ExtractTextForProjection(message.Parts), "content:\ntool output") { foundToolResult = true break } @@ -537,7 +537,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 || providertypes.ExtractTextForProjection(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" { @@ -547,7 +547,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"), @@ -591,12 +591,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(providertypes.ExtractTextForProjection(message.Parts), "tool result") && + strings.Contains(providertypes.ExtractTextForProjection(message.Parts), "tool: filesystem_read_file") && + strings.Contains(providertypes.ExtractTextForProjection(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(providertypes.ExtractTextForProjection(message.Parts), "content:\n") { + t.Fatalf("expected metadata-only projection to omit content section, got %q", providertypes.ExtractTextForProjection(message.Parts)) } break } @@ -606,7 +606,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 || providertypes.ExtractTextForProjection(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" || @@ -694,7 +694,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 { @@ -731,7 +731,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 { @@ -772,7 +772,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 { @@ -806,7 +806,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 { @@ -838,7 +838,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) } @@ -889,7 +889,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) } @@ -915,7 +915,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) } @@ -941,7 +941,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) } @@ -975,7 +975,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 { @@ -1027,7 +1027,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 }, @@ -1040,7 +1040,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) } @@ -1066,7 +1066,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 || providertypes.ExtractTextForProjection(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 { @@ -1075,7 +1075,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 || providertypes.ExtractTextForProjection(scripted.requests[0].Messages[0].Parts) != "delegated message" { t.Fatalf("expected delegated messages, got %+v", scripted.requests[0].Messages) } } @@ -1106,13 +1106,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) } @@ -1136,7 +1136,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) } @@ -1163,34 +1163,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", }}, } @@ -1199,7 +1199,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) } @@ -1207,7 +1207,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 := providertypes.ExtractTextForProjection(scripted.requests[0].Messages[2].Parts); got != "preserved result" { t.Fatalf("expected preserved tool result to remain visible, got %q", got) } } @@ -1226,34 +1226,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", }}, } @@ -1262,7 +1262,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) } @@ -1270,7 +1270,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 := providertypes.ExtractTextForProjection(scripted.requests[0].Messages[2].Parts); got != "preserved result" { t.Fatalf("expected preserved tool result to remain visible, got %q", got) } } @@ -1297,7 +1297,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) @@ -1310,7 +1310,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) @@ -1326,7 +1326,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 || providertypes.ExtractTextForProjection(saved.Messages[1].Parts) != "continue" { t.Fatalf("expected failed run to append only user message, got %+v", saved.Messages) } } @@ -1360,7 +1360,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) } @@ -1381,7 +1381,7 @@ func TestServiceRunUsesToolManager(t *testing.T) { foundToolMessage := false for _, message := range session.Messages { if message.Role == providertypes.RoleTool && - message.Content == "tool manager output" && + providertypes.ExtractTextForProjection(message.Parts) == "tool manager output" && message.ToolMetadata["tool_name"] == "filesystem_edit" && message.ToolMetadata["path"] == "main.go" { foundToolMessage = true @@ -1435,7 +1435,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 @@ -1549,7 +1549,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 { @@ -1637,7 +1637,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", }, }, @@ -1647,7 +1647,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) } @@ -1685,7 +1685,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) @@ -1713,7 +1713,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) } @@ -1735,7 +1735,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() @@ -1746,7 +1747,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++ { @@ -1764,7 +1765,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} @@ -1780,14 +1781,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 providertypes.ExtractTextForProjection(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{ @@ -1800,7 +1801,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{ @@ -1813,7 +1814,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}, @@ -1830,7 +1831,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{ @@ -1861,14 +1862,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 providertypes.ExtractTextForProjection(session.Messages[1].Parts) != "recovered" { + t.Fatalf("expected assistant content %q, got %q", "recovered", providertypes.ExtractTextForProjection(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 { @@ -1891,7 +1892,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 { @@ -1979,7 +1980,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) @@ -2027,7 +2028,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 { @@ -2037,7 +2038,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) @@ -2097,7 +2098,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) @@ -2142,7 +2143,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) @@ -2196,7 +2197,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) @@ -2257,7 +2258,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) @@ -2301,7 +2302,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) @@ -2346,7 +2347,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) } @@ -2370,9 +2371,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) @@ -2383,8 +2384,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{ @@ -2413,7 +2414,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(providertypes.ExtractTextForProjection(saved.Messages[0].Parts), "compact_summary") { t.Fatalf("expected persisted compacted messages, got %+v", saved.Messages) } @@ -2428,9 +2429,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) @@ -2452,7 +2453,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 || providertypes.ExtractTextForProjection(saved.Messages[2].Parts) != "before" { t.Fatalf("expected original session untouched, got %+v", saved.Messages) } @@ -2485,9 +2486,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) @@ -2550,9 +2551,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) @@ -2591,8 +2592,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) @@ -2615,8 +2616,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{ @@ -2640,7 +2641,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) } @@ -2653,7 +2654,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(providertypes.ExtractTextForProjection(saved.Messages[0].Parts), "compact_summary") { t.Fatalf("expected compacted history + new tool round, got %+v", saved.Messages) } @@ -2715,7 +2716,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")}, }) }() @@ -2827,7 +2828,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) } @@ -2864,7 +2865,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) @@ -3268,8 +3269,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) @@ -3296,8 +3297,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) @@ -3383,7 +3384,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")}}}, }, } @@ -3433,8 +3434,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) } @@ -3483,7 +3484,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) } @@ -3551,8 +3552,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) @@ -3580,7 +3581,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", }, }, @@ -3590,8 +3591,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{ @@ -3609,7 +3610,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) } @@ -3644,10 +3645,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 providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(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) } @@ -3712,8 +3713,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) @@ -3772,8 +3773,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{ @@ -3794,7 +3795,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) } @@ -3823,8 +3824,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) @@ -3870,8 +3871,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{ @@ -3888,7 +3889,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) } @@ -3923,8 +3924,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 providertypes.ExtractTextForProjection(saved.Messages[2].Parts) != "recovered" { + t.Fatalf("expected final assistant reply %q, got %q", "recovered", providertypes.ExtractTextForProjection(saved.Messages[2].Parts)) } events := collectRuntimeEvents(service.Events()) @@ -3965,8 +3966,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) @@ -4000,8 +4001,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{ @@ -4016,7 +4017,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) } @@ -4060,8 +4061,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) @@ -4122,8 +4123,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) @@ -4360,8 +4361,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", }, @@ -4378,8 +4379,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 { @@ -4468,8 +4469,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", }, @@ -4485,7 +4486,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) } @@ -4528,8 +4529,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", }, @@ -4545,7 +4546,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) } @@ -4612,7 +4613,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") } @@ -4634,8 +4635,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", }, @@ -4645,7 +4646,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/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..d36f1615 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 || providertypes.ExtractTextForProjection(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 providertypes.ExtractTextForProjection(loaded.Messages[2].Parts) != "" { + t.Fatalf("expected empty content to round-trip, got %q", providertypes.ExtractTextForProjection(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..9fbc9462 100644 --- a/internal/tools/format.go +++ b/internal/tools/format.go @@ -122,8 +122,8 @@ 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) + if strings.TrimSpace(providertypes.ExtractTextForProjection(message.Parts)) != "" { + lines = append(lines, "", "content:", providertypes.ExtractTextForProjection(message.Parts)) } return strings.Join(lines, "\n") 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/update.go b/internal/tui/core/app/update.go index 63756cc5..52158d58 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -425,7 +425,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te } 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 @@ -617,18 +617,6 @@ 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) - a.closePicker() - if !ok { - return a, nil - } - if err := a.activateSessionByID(item.Summary.ID); 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() @@ -963,9 +951,6 @@ func runtimeEventRunContextHandler(a *App, event agentruntime.RuntimeEvent) bool a.state.CurrentProvider = mapped.Provider } if strings.TrimSpace(mapped.Model) != "" { - if !strings.EqualFold(strings.TrimSpace(a.state.CurrentModel), strings.TrimSpace(mapped.Model)) { - a.invalidateModelCapabilityCache() - } a.state.CurrentModel = mapped.Model } if strings.TrimSpace(mapped.Workdir) != "" { @@ -1036,7 +1021,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 { @@ -1083,8 +1068,8 @@ 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 && strings.TrimSpace(providertypes.ExtractTextForProjection(payload.Parts)) != "" && !a.lastAssistantMatches(providertypes.ExtractTextForProjection(payload.Parts)) { + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart(providertypes.ExtractTextForProjection(payload.Parts))}}) return true } return false @@ -1241,12 +1226,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 := providertypes.ExtractTextForProjection(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) { @@ -1255,7 +1241,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) { @@ -1307,7 +1293,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(providertypes.ExtractTextForProjection(last.Parts)) == strings.TrimSpace(content) } func (a *App) handleViewportKeys(vp *viewport.Model, msg tea.KeyMsg) { @@ -1876,7 +1862,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 c6daa2dd..ae3faea7 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -130,6 +130,10 @@ func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, w return agentsession.NewWithWorkdir("draft", workdir), nil } +func messageText(message providertypes.Message) string { + return providertypes.ExtractTextForProjection(message.Parts) +} + func newDefaultAppConfig() *config.Config { cfg := config.StaticDefaults() cfg.Providers = config.DefaultProviders() @@ -469,7 +473,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) } } @@ -493,7 +497,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") @@ -1119,7 +1123,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")}}, }, }, } @@ -1294,7 +1298,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 ") @@ -1653,8 +1657,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)) } }) @@ -1665,11 +1669,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)) } }) @@ -1684,8 +1688,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)) } }) } @@ -1701,8 +1705,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()) @@ -1719,8 +1723,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)) } }) @@ -1729,8 +1733,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)) } }) @@ -1739,8 +1743,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)) } }) } @@ -1756,8 +1760,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()) @@ -1774,8 +1778,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)) } }) @@ -1784,8 +1788,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)) } }) @@ -1794,8 +1798,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)) } }) } diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 42296611..613f7a8c 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -223,11 +223,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(providertypes.ExtractTextForProjection(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(providertypes.ExtractTextForProjection(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(providertypes.ExtractTextForProjection(message.Parts), max(16, width-6))), nil } maxMessageWidth := tuiutils.Clamp(int(float64(width)*0.84), 24, width) @@ -245,7 +245,7 @@ func (a App) renderMessageBlockWithCopy(message providertypes.Message, width int return "", nil } - content := strings.TrimSpace(message.Content) + content := strings.TrimSpace(providertypes.ExtractTextForProjection(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 0c807d5a..8df2c52e 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -165,8 +165,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, "") diff --git a/internal/tui/services/services_test.go b/internal/tui/services/services_test.go index 39afd6f3..f50a1a15 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" || providertypes.ExtractTextForProjection(runner.lastInput.Parts) != "hello" { t.Fatalf("unexpected runner input: %+v", runner.lastInput) } if err, ok := msg.(error); !ok || err == nil || err.Error() != "boom" { From 0728dc69e738b4ac83a510f717700596b38aa4a8 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Wed, 15 Apr 2026 17:35:11 +0000 Subject: [PATCH 02/10] fix(tui): remove legacy compatibility path and restore multimodal/session flow Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/tui/core/app/input_features_test.go | 4 ++-- internal/tui/core/app/update.go | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/tui/core/app/input_features_test.go b/internal/tui/core/app/input_features_test.go index db32b19a..bb1b2386 100644 --- a/internal/tui/core/app/input_features_test.go +++ b/internal/tui/core/app/input_features_test.go @@ -448,7 +448,7 @@ func TestUpdateSendWithImageAttachmentsComposesRuntimeInput(t *testing.T) { if app.hasImageAttachments() { t.Fatalf("expected attachments cleared after send") } - if len(app.activeMessages) == 0 || !strings.Contains(app.activeMessages[len(app.activeMessages)-1].Content, "[Attached images]") { + if len(app.activeMessages) == 0 || !strings.Contains(providertypes.ExtractTextForProjection(app.activeMessages[len(app.activeMessages)-1].Parts), "[Attached images]") { t.Fatalf("expected composed user message in transcript") } @@ -457,7 +457,7 @@ func TestUpdateSendWithImageAttachmentsComposesRuntimeInput(t *testing.T) { if !ok { t.Fatalf("expected runFinishedMsg, got %T", msg) } - if len(runtime.runInputs) != 1 || !strings.Contains(runtime.runInputs[0].Content, "[Attached images]") { + if len(runtime.runInputs) != 1 || !strings.Contains(providertypes.ExtractTextForProjection(runtime.runInputs[0].Parts), "[Attached images]") { t.Fatalf("expected composed runtime input, got %+v", runtime.runInputs) } } diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 52158d58..64472b1c 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -425,7 +425,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te } composedInput := a.composeMessageWithImageAttachments(input) - a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart(input)}}) + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart(composedInput)}}) a.rebuildTranscript() runID := fmt.Sprintf("run-%d", a.now().UnixNano()) a.state.ActiveRunID = runID @@ -624,6 +624,17 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, 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("session", "Failed to activate session", err.Error(), true) + return a, nil + } + a.rebuildTranscript() + a.state.StatusText = statusReady + return a, nil } } @@ -951,6 +962,9 @@ func runtimeEventRunContextHandler(a *App, event agentruntime.RuntimeEvent) bool a.state.CurrentProvider = mapped.Provider } if strings.TrimSpace(mapped.Model) != "" { + if !strings.EqualFold(strings.TrimSpace(a.state.CurrentModel), strings.TrimSpace(mapped.Model)) { + a.invalidateModelCapabilityCache() + } a.state.CurrentModel = mapped.Model } if strings.TrimSpace(mapped.Workdir) != "" { From 6618ee6a645f5a340579fe9dccaabbccc61afb65 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 16 Apr 2026 02:27:15 +0000 Subject: [PATCH 03/10] fix(provider): validate parts and simplify message conversion Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- .../chatcompletions/provider_test.go | 52 +++++++++++++------ .../openaicompat/chatcompletions/request.go | 27 ++++++---- 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/internal/provider/openaicompat/chatcompletions/provider_test.go b/internal/provider/openaicompat/chatcompletions/provider_test.go index b4dd0484..143b1614 100644 --- a/internal/provider/openaicompat/chatcompletions/provider_test.go +++ b/internal/provider/openaicompat/chatcompletions/provider_test.go @@ -85,6 +85,20 @@ func TestNewAndBuildRequest(t *testing.T) { t.Fatalf("unexpected user message: %+v", user) } + 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{ @@ -140,6 +154,16 @@ func TestNewAndBuildRequest(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "unsupported image part payload") { 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) + } }) } @@ -348,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{}) @@ -393,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 } @@ -539,7 +567,7 @@ func TestGenerateVariants(t *testing.T) { } if err := mustProvider(t).Generate(context.Background(), providertypes.GenerateRequest{ - Messages: []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("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) @@ -550,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, Parts: []providertypes.ContentPart{providertypes.NewTextPart("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) } @@ -562,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, Parts: []providertypes.ContentPart{providertypes.NewTextPart("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) } }) @@ -582,9 +606,7 @@ 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, Parts: []providertypes.ContentPart{providertypes.NewTextPart("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) } }) diff --git a/internal/provider/openaicompat/chatcompletions/request.go b/internal/provider/openaicompat/chatcompletions/request.go index 76eeacbd..283360f1 100644 --- a/internal/provider/openaicompat/chatcompletions/request.go +++ b/internal/provider/openaicompat/chatcompletions/request.go @@ -49,14 +49,15 @@ func BuildRequest(cfg provider.RuntimeConfig, req providertypes.GenerateRequest) 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) } } @@ -65,6 +66,10 @@ func BuildRequest(cfg provider.RuntimeConfig, req providertypes.GenerateRequest) // ToOpenAIMessage 将通用 Message 转换为 OpenAI 协议消息格式。 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, ToolCallID: message.ToolCallID, @@ -79,34 +84,36 @@ func ToOpenAIMessage(message providertypes.Message) (Message, error) { } if !hasImage { - var text string + var textBuilder strings.Builder for _, part := range message.Parts { if part.Kind == providertypes.ContentPartText { - text += part.Text + textBuilder.WriteString(part.Text) } } - if text != "" { + if text := textBuilder.String(); text != "" { out.Content = text } } else { var contentParts []MessageContentPart for _, part := range message.Parts { - if part.Kind == providertypes.ContentPartText { + switch part.Kind { + case providertypes.ContentPartText: contentParts = append(contentParts, MessageContentPart{ Type: "text", Text: part.Text, }) - } else if part.Kind == providertypes.ContentPartImage { - if part.Image != nil && part.Image.SourceType == providertypes.ImageSourceRemote { + 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, }, }) - } else if part.Image != nil && part.Image.SourceType == providertypes.ImageSourceSessionAsset { + case part.Image != nil && part.Image.SourceType == providertypes.ImageSourceSessionAsset: return Message{}, errors.New("session_asset image is not supported in this phase") - } else { + default: return Message{}, errors.New("unsupported image part payload") } } From 7107222e43a94e865bf78702854376580e879b15 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 16 Apr 2026 02:46:07 +0000 Subject: [PATCH 04/10] fix(runtime): align todo integration test with parts input Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- .../runtime/todo_runtime_integration_test.go | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 internal/runtime/todo_runtime_integration_test.go diff --git a/internal/runtime/todo_runtime_integration_test.go b/internal/runtime/todo_runtime_integration_test.go new file mode 100644 index 00000000..2e41c491 --- /dev/null +++ b/internal/runtime/todo_runtime_integration_test.go @@ -0,0 +1,115 @@ +package runtime + +import ( + "context" + "strings" + "testing" + + providertypes "neo-code/internal/provider/types" + "neo-code/internal/tools" +) + +func TestServiceRunToolCallWithPartsInput(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + toolManager := &stubToolManager{ + specs: []providertypes.ToolSpec{ + { + Name: tools.ToolNameFilesystemReadFile, + Description: "read file", + Schema: map[string]any{"type": "object"}, + }, + }, + result: tools.ToolResult{ + Name: tools.ToolNameFilesystemReadFile, + Content: "ok", + }, + } + + providerImpl := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + { + ID: "tool-call-1", + Name: tools.ToolNameFilesystemReadFile, + Arguments: `{"path":"README.md"}`, + }, + }, + }, + FinishReason: "tool_calls", + }, + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}, + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory( + manager, + toolManager, + store, + &scriptedProviderFactory{provider: providerImpl}, + &stubContextBuilder{}, + ) + + if err := service.Run(context.Background(), UserInput{ + RunID: "run-parts-tool", + Parts: []providertypes.ContentPart{providertypes.NewTextPart("请记录一个待办并继续")}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(providerImpl.requests) < 1 { + t.Fatalf("expected provider requests, got 0") + } + toolFound := false + for _, spec := range providerImpl.requests[0].Tools { + if strings.EqualFold(strings.TrimSpace(spec.Name), tools.ToolNameFilesystemReadFile) { + toolFound = true + break + } + } + if !toolFound { + t.Fatalf("expected first request tools to include %q", tools.ToolNameFilesystemReadFile) + } + + session := onlySession(t, store) + if len(session.Messages) == 0 { + t.Fatalf("expected session messages, got 0") + } + userMsg := session.Messages[0] + if userMsg.Role != providertypes.RoleUser { + t.Fatalf("expected first message role user, got %q", userMsg.Role) + } + if got := providertypes.ExtractTextForProjection(userMsg.Parts); got != "请记录一个待办并继续" { + t.Fatalf("expected first message parts text to persist, got %q", got) + } + + if toolManager.executeCalls != 1 { + t.Fatalf("expected 1 tool execute call, got %d", toolManager.executeCalls) + } + if toolManager.lastInput.Name != tools.ToolNameFilesystemReadFile { + t.Fatalf("unexpected tool call name: %q", toolManager.lastInput.Name) + } + + events := collectRuntimeEvents(service.Events()) + foundToolResult := false + for _, event := range events { + if event.Type == EventToolResult { + foundToolResult = true + break + } + } + if !foundToolResult { + t.Fatalf("expected %q event in runtime events", EventToolResult) + } +} From 491af121af19b24a9a1fd9d75beb1be7e2ee7171 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 16 Apr 2026 19:21:49 +0800 Subject: [PATCH 05/10] =?UTF-8?q?pref(runtime\provider):=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=A4=9A=E6=A8=A1=E6=80=81=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/bootstrap_test.go | 4 +- internal/app/parts_render_test.go | 20 +++++ internal/config/provider.go | 2 +- internal/context/builder_test.go | 36 ++++---- internal/context/compact/helpers.go | 2 +- internal/context/compact/parts_render.go | 11 +++ internal/context/compact/planner_test.go | 2 +- internal/context/compact/runner.go | 2 +- internal/context/compact/runner_test.go | 6 +- internal/context/compact/transcript_store.go | 2 +- internal/context/compact_prompt.go | 2 +- internal/context/compact_prompt_test.go | 17 ++++ internal/context/internalcompact/messages.go | 21 ++++- .../context/internalcompact/messages_test.go | 19 ++++ internal/context/microcompact.go | 2 +- internal/context/microcompact_test.go | 88 +++++++++---------- internal/context/parts_render.go | 40 +++++++++ internal/context/parts_render_test.go | 45 ++++++++++ internal/context/projection.go | 4 +- internal/context/projection_test.go | 22 ++--- internal/memo/auto_extractor_test.go | 6 +- internal/memo/extractor.go | 2 +- internal/memo/llm_extractor.go | 2 +- internal/memo/llm_extractor_test.go | 30 +++++-- internal/memo/message_clone_test.go | 2 +- internal/memo/parts_projection.go | 30 +++++++ internal/memo/parts_projection_test.go | 30 +++++++ internal/partsrender/render.go | 81 +++++++++++++++++ internal/partsrender/render_test.go | 70 +++++++++++++++ .../chatcompletions/provider_test.go | 42 +++++++++ internal/provider/types/content_part.go | 15 ---- internal/provider/types/parts_render_test.go | 16 ++++ internal/provider/types/types_test.go | 2 +- internal/runtime/compact_generator.go | 16 +++- internal/runtime/compact_generator_test.go | 2 +- internal/runtime/parts_render_test.go | 20 +++++ internal/runtime/run.go | 19 +++- internal/runtime/runtime_gap_coverage_test.go | 4 +- .../runtime/runtime_internal_helpers_test.go | 27 ++++-- internal/runtime/runtime_test.go | 60 ++++++------- internal/session/parts_render_test.go | 20 +++++ internal/session/store_test.go | 6 +- internal/tools/format.go | 11 ++- internal/tui/core/app/input_features.go | 27 ------ internal/tui/core/app/input_features_test.go | 49 +++-------- internal/tui/core/app/parts_render.go | 11 +++ internal/tui/core/app/update.go | 27 +++--- internal/tui/core/app/update_test.go | 2 +- internal/tui/core/app/view.go | 8 +- internal/tui/services/parts_render_test.go | 20 +++++ internal/tui/services/services_test.go | 2 +- 51 files changed, 761 insertions(+), 245 deletions(-) create mode 100644 internal/app/parts_render_test.go create mode 100644 internal/context/compact/parts_render.go create mode 100644 internal/context/parts_render.go create mode 100644 internal/context/parts_render_test.go create mode 100644 internal/memo/parts_projection.go create mode 100644 internal/memo/parts_projection_test.go create mode 100644 internal/partsrender/render.go create mode 100644 internal/partsrender/render_test.go create mode 100644 internal/provider/types/parts_render_test.go create mode 100644 internal/runtime/parts_render_test.go create mode 100644 internal/session/parts_render_test.go create mode 100644 internal/tui/core/app/parts_render.go create mode 100644 internal/tui/services/parts_render_test.go diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index a55f2b00..7127b3e1 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -1121,7 +1121,7 @@ func TestRuntimeMemoExtractorFuncSchedule(t *testing.T) { if sessionID != "session-1" { t.Fatalf("unexpected session id %q", sessionID) } - if len(messages) != 1 || providertypes.ExtractTextForProjection(messages[0].Parts) != "hi" { + if len(messages) != 1 || renderPartsForTest(messages[0].Parts) != "hi" { t.Fatalf("unexpected messages %+v", messages) } }) @@ -1144,7 +1144,7 @@ func TestTextGenAdapterGenerate(t *testing.T) { if prompt != "prompt" { t.Fatalf("unexpected prompt %q", prompt) } - if len(msgs) != 1 || providertypes.ExtractTextForProjection(msgs[0].Parts) != "msg" { + if len(msgs) != 1 || renderPartsForTest(msgs[0].Parts) != "msg" { t.Fatalf("unexpected messages %+v", msgs) } return "ok", 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 46f01e27..8b7b7e05 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -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 providertypes.ExtractTextForProjection(got.Messages[0].Parts) != "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]) } } @@ -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 providertypes.ExtractTextForProjection(got.Messages[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected builder output to clear older tool result, got %q", providertypes.ExtractTextForProjection(got.Messages[2].Parts)) + 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 providertypes.ExtractTextForProjection(got.Messages[4].Parts) != "recent bash result" { - t.Fatalf("expected recent tool result to stay visible, got %q", providertypes.ExtractTextForProjection(got.Messages[4].Parts)) + 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 providertypes.ExtractTextForProjection(got.Messages[6].Parts) != "latest webfetch result" { - t.Fatalf("expected latest tool result to stay visible, got %q", providertypes.ExtractTextForProjection(got.Messages[6].Parts)) + 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)) } } @@ -310,8 +310,8 @@ func TestDefaultBuilderBuildSkipsMicroCompactWithoutEstablishedTaskState(t *test if err != nil { t.Fatalf("Build() error = %v", err) } - if providertypes.ExtractTextForProjection(got.Messages[2].Parts) != "old read result" { - t.Fatalf("expected old tool result to remain visible without task state, got %q", providertypes.ExtractTextForProjection(got.Messages[2].Parts)) + 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)) } } @@ -410,8 +410,8 @@ func TestDefaultBuilderBuildHonorsToolMicroCompactPolicies(t *testing.T) { if err != nil { t.Fatalf("Build() error = %v", err) } - if providertypes.ExtractTextForProjection(got.Messages[2].Parts) != "old custom result" { - t.Fatalf("expected preserved tool result to remain, got %q", providertypes.ExtractTextForProjection(got.Messages[2].Parts)) + if renderDisplayParts(got.Messages[2].Parts) != "old custom result" { + t.Fatalf("expected preserved tool result to remain, got %q", renderDisplayParts(got.Messages[2].Parts)) } } @@ -452,8 +452,8 @@ func TestNewBuilderWithToolPoliciesUsesProvidedPolicySource(t *testing.T) { if err != nil { t.Fatalf("Build() error = %v", err) } - if providertypes.ExtractTextForProjection(got.Messages[2].Parts) != "old custom result" { - t.Fatalf("expected preserved tool result to remain, got %q", providertypes.ExtractTextForProjection(got.Messages[2].Parts)) + if renderDisplayParts(got.Messages[2].Parts) != "old custom result" { + t.Fatalf("expected preserved tool result to remain, got %q", renderDisplayParts(got.Messages[2].Parts)) } } @@ -521,7 +521,7 @@ func TestTrimMessagesProtectsLatestExplicitUserInstructionTail(t *testing.T) { ) trimmed := trimMessages(messages, retainedSpans) - if trimmed[0].Role != providertypes.RoleUser || providertypes.ExtractTextForProjection(trimmed[0].Parts) != "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 { @@ -561,7 +561,7 @@ func TestTrimMessagesUsesSharedSpanModel(t *testing.T) { trimmed := trimMessages(messages, retainedSpans) start := spans[len(spans)-retainedSpans].Start - if len(trimmed) == 0 || providertypes.ExtractTextForProjection(trimmed[0].Parts) != providertypes.ExtractTextForProjection(messages[start].Parts) { + 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 { @@ -640,7 +640,7 @@ func TestTrimMessagesBoundaries(t *testing.T) { wantLen: maxRetainedMessageSpans + 1, assert: func(t *testing.T, original []providertypes.Message, trimmed []providertypes.Message) { t.Helper() - if providertypes.ExtractTextForProjection(trimmed[0].Parts) != "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" { @@ -800,7 +800,7 @@ func TestProjectToolMessagesForModelKeepsBuilderProjectionBehavior(t *testing.T) if len(projected) != 1 { t.Fatalf("len(projected) = %d, want 1", len(projected)) } - projectedText := providertypes.ExtractTextForProjection(projected[0].Parts) + 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) } @@ -844,7 +844,7 @@ func TestDefaultBuilderBuildProjectsMetadataOnlyToolResult(t *testing.T) { if toolMessage.Role != providertypes.RoleTool { t.Fatalf("expected tool message at index 2, got %+v", toolMessage) } - toolMessageText := providertypes.ExtractTextForProjection(toolMessage.Parts) + toolMessageText := renderDisplayParts(toolMessage.Parts) if !strings.Contains(toolMessageText, "tool result") || !strings.Contains(toolMessageText, "tool: filesystem_read_file") || !strings.Contains(toolMessageText, "meta.path: README.md") { diff --git a/internal/context/compact/helpers.go b/internal/context/compact/helpers.go index cac32c38..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(providertypes.ExtractTextForProjection(message.Parts)) + 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 ee368e15..dc5cf0c1 100644 --- a/internal/context/compact/planner_test.go +++ b/internal/context/compact/planner_test.go @@ -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 || providertypes.ExtractTextForProjection(plan.Retained[0].Parts) != "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) } } diff --git a/internal/context/compact/runner.go b/internal/context/compact/runner.go index 57f0f281..bfa6aafd 100644 --- a/internal/context/compact/runner.go +++ b/internal/context/compact/runner.go @@ -285,7 +285,7 @@ func isCompactSummaryMessage(message providertypes.Message) bool { if message.Role != providertypes.RoleAssistant { return false } - return strings.HasPrefix(strings.TrimSpace(providertypes.ExtractTextForProjection(message.Parts)), "[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 69d59bcb..8e56764d 100644 --- a/internal/context/compact/runner_test.go +++ b/internal/context/compact/runner_test.go @@ -112,7 +112,7 @@ 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:"} { - summaryText := providertypes.ExtractTextForProjection(result.Messages[0].Parts) + summaryText := renderTranscriptParts(result.Messages[0].Parts) if !strings.Contains(summaryText, section) { t.Fatalf("expected summary to include section %q, got %q", section, summaryText) } @@ -184,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(providertypes.ExtractTextForProjection(generator.calls[0].ArchivedMessages[0].Parts)), "[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) } } @@ -348,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 || providertypes.ExtractTextForProjection(result.Messages[1].Parts) != "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]) } } diff --git a/internal/context/compact/transcript_store.go b/internal/context/compact/transcript_store.go index dc569cee..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: providertypes.ExtractTextForProjection(message.Parts), + Content: renderTranscriptParts(message.Parts), ToolCalls: append([]providertypes.ToolCall(nil), message.ToolCalls...), ToolCallID: message.ToolCallID, IsError: message.IsError, diff --git a/internal/context/compact_prompt.go b/internal/context/compact_prompt.go index 7c163443..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(providertypes.ExtractTextForProjection(message.Parts)) + 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 4dd3de44..d06eb599 100644 --- a/internal/context/compact_prompt_test.go +++ b/internal/context/compact_prompt_test.go @@ -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 21ff4801..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(providertypes.ExtractTextForProjection(messages[index].Parts)) != "" { + 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 a8ad9815..371465d5 100644 --- a/internal/context/internalcompact/messages_test.go +++ b/internal/context/internalcompact/messages_test.go @@ -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 3393b4ef..6627d459 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -150,6 +150,6 @@ func shouldClearToolMessage(message providertypes.Message, compactableIDs map[st return false } - content := strings.TrimSpace(providertypes.ExtractTextForProjection(message.Parts)) + 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 899793f4..6077100a 100644 --- a/internal/context/microcompact_test.go +++ b/internal/context/microcompact_test.go @@ -50,17 +50,17 @@ func TestMicroCompactMessagesClearsOlderCompactableToolResults(t *testing.T) { if len(got) != len(messages) { t.Fatalf("expected message count to stay unchanged, got %d want %d", len(got), len(messages)) } - if providertypes.ExtractTextForProjection(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected oldest compactable tool result to be cleared, got %q", providertypes.ExtractTextForProjection(got[2].Parts)) + if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected oldest compactable tool result to be cleared, got %q", renderDisplayParts(got[2].Parts)) } - if providertypes.ExtractTextForProjection(got[4].Parts) != "recent bash result" { - t.Fatalf("expected recent compactable tool result to be retained, got %q", providertypes.ExtractTextForProjection(got[4].Parts)) + 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 providertypes.ExtractTextForProjection(got[6].Parts) != "latest webfetch result" { - t.Fatalf("expected latest compactable tool result to be retained, got %q", providertypes.ExtractTextForProjection(got[6].Parts)) + 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 providertypes.ExtractTextForProjection(messages[2].Parts) != "old read result" { - t.Fatalf("expected original slice to remain unchanged, got %q", providertypes.ExtractTextForProjection(messages[2].Parts)) + if renderDisplayParts(messages[2].Parts) != "old read result" { + t.Fatalf("expected original slice to remain unchanged, got %q", renderDisplayParts(messages[2].Parts)) } } @@ -122,17 +122,17 @@ func TestMicroCompactMessagesKeepsProtectedTailUntouched(t *testing.T) { } got := microCompactMessages(messages) - if providertypes.ExtractTextForProjection(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected old tool result before protected tail to be cleared, got %q", providertypes.ExtractTextForProjection(got[2].Parts)) + 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 providertypes.ExtractTextForProjection(got[4].Parts) != "recent read result" { - t.Fatalf("expected recent tool result before protected tail to remain, got %q", providertypes.ExtractTextForProjection(got[4].Parts)) + 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 providertypes.ExtractTextForProjection(got[6].Parts) != "recent bash result" { - t.Fatalf("expected second recent tool result before protected tail to remain, got %q", providertypes.ExtractTextForProjection(got[6].Parts)) + 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 providertypes.ExtractTextForProjection(got[9].Parts) != "tail bash result" { - t.Fatalf("expected protected tail tool result to remain, got %q", providertypes.ExtractTextForProjection(got[9].Parts)) + if renderDisplayParts(got[9].Parts) != "tail bash result" { + t.Fatalf("expected protected tail tool result to remain, got %q", renderDisplayParts(got[9].Parts)) } } @@ -174,20 +174,20 @@ func TestMicroCompactMessagesKeepsPreservedToolsErrorsAndOrphans(t *testing.T) { got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ "custom_tool": tools.MicroCompactPolicyPreserveHistory, }, 0) - if providertypes.ExtractTextForProjection(got[1].Parts) != "custom result" { - t.Fatalf("expected preserved tool result to remain, got %q", providertypes.ExtractTextForProjection(got[1].Parts)) + if renderDisplayParts(got[1].Parts) != "custom result" { + t.Fatalf("expected preserved tool result to remain, got %q", renderDisplayParts(got[1].Parts)) } - if providertypes.ExtractTextForProjection(got[3].Parts) != "edit failed" { - t.Fatalf("expected error tool result to remain, got %q", providertypes.ExtractTextForProjection(got[3].Parts)) + if renderDisplayParts(got[3].Parts) != "edit failed" { + t.Fatalf("expected error tool result to remain, got %q", renderDisplayParts(got[3].Parts)) } - if providertypes.ExtractTextForProjection(got[4].Parts) != "orphan result" { - t.Fatalf("expected orphan tool result to remain, got %q", providertypes.ExtractTextForProjection(got[4].Parts)) + if renderDisplayParts(got[4].Parts) != "orphan result" { + t.Fatalf("expected orphan tool result to remain, got %q", renderDisplayParts(got[4].Parts)) } - if providertypes.ExtractTextForProjection(got[6].Parts) != microCompactClearedMessage { - t.Fatalf("expected already cleared content to remain unchanged, got %q", providertypes.ExtractTextForProjection(got[6].Parts)) + if renderDisplayParts(got[6].Parts) != microCompactClearedMessage { + t.Fatalf("expected already cleared content to remain unchanged, got %q", renderDisplayParts(got[6].Parts)) } - if providertypes.ExtractTextForProjection(got[8].Parts) != "" { - t.Fatalf("expected empty tool result to remain empty, got %q", providertypes.ExtractTextForProjection(got[8].Parts)) + if renderDisplayParts(got[8].Parts) != "" { + t.Fatalf("expected empty tool result to remain empty, got %q", renderDisplayParts(got[8].Parts)) } } @@ -226,11 +226,11 @@ func TestMicroCompactMessagesClearsOnlyNonPreservedResultsInMixedToolSpan(t *tes got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ "custom_tool": tools.MicroCompactPolicyPreserveHistory, }, 0) - if providertypes.ExtractTextForProjection(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected default compactable tool result to be cleared, got %q", providertypes.ExtractTextForProjection(got[2].Parts)) + if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected default compactable tool result to be cleared, got %q", renderDisplayParts(got[2].Parts)) } - if providertypes.ExtractTextForProjection(got[3].Parts) != "custom result" { - t.Fatalf("expected preserved tool result in mixed span to remain, got %q", providertypes.ExtractTextForProjection(got[3].Parts)) + 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) @@ -267,8 +267,8 @@ func TestMicroCompactMessagesTreatsNewToolsAsCompactableByDefault(t *testing.T) } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 0) - if providertypes.ExtractTextForProjection(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected new tool result to be compacted by default, got %q", providertypes.ExtractTextForProjection(got[2].Parts)) + if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected new tool result to be compacted by default, got %q", renderDisplayParts(got[2].Parts)) } } @@ -317,20 +317,20 @@ func TestMicroCompactMessagesSkipsEmptyRecentSpansWhenCountingRetainedBudget(t * } got := microCompactMessages(messages) - if providertypes.ExtractTextForProjection(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected oldest valid tool result to be cleared, got %q", providertypes.ExtractTextForProjection(got[2].Parts)) + if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected oldest valid tool result to be cleared, got %q", renderDisplayParts(got[2].Parts)) } - if providertypes.ExtractTextForProjection(got[4].Parts) != "middle grep result" { - t.Fatalf("expected middle valid tool result to remain, got %q", providertypes.ExtractTextForProjection(got[4].Parts)) + if renderDisplayParts(got[4].Parts) != "middle grep result" { + t.Fatalf("expected middle valid tool result to remain, got %q", renderDisplayParts(got[4].Parts)) } - if providertypes.ExtractTextForProjection(got[6].Parts) != "near edit result" { - t.Fatalf("expected nearer valid tool result to remain, got %q", providertypes.ExtractTextForProjection(got[6].Parts)) + if renderDisplayParts(got[6].Parts) != "near edit result" { + t.Fatalf("expected nearer valid tool result to remain, got %q", renderDisplayParts(got[6].Parts)) } - if providertypes.ExtractTextForProjection(got[8].Parts) != "" { - t.Fatalf("expected error/empty tool result to remain unchanged, got %q", providertypes.ExtractTextForProjection(got[8].Parts)) + if renderDisplayParts(got[8].Parts) != "" { + t.Fatalf("expected error/empty tool result to remain unchanged, got %q", renderDisplayParts(got[8].Parts)) } - if providertypes.ExtractTextForProjection(got[10].Parts) != "" { - t.Fatalf("expected empty recent tool result to remain unchanged, got %q", providertypes.ExtractTextForProjection(got[10].Parts)) + if renderDisplayParts(got[10].Parts) != "" { + t.Fatalf("expected empty recent tool result to remain unchanged, got %q", renderDisplayParts(got[10].Parts)) } } @@ -342,7 +342,7 @@ func TestMicroCompactMessagesSkipsToolMessagesWhenCompactableIDsMissing(t *testi } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 0) - if providertypes.ExtractTextForProjection(got[0].Parts) != "orphan result" { - t.Fatalf("expected orphan tool result to remain, got %q", providertypes.ExtractTextForProjection(got[0].Parts)) + 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 2e7c8bdd..ac7da4b3 100644 --- a/internal/context/projection.go +++ b/internal/context/projection.go @@ -144,7 +144,7 @@ func isInjectableToolMessage(message providertypes.Message) bool { if message.Role != providertypes.RoleTool { return false } - content := strings.TrimSpace(providertypes.ExtractTextForProjection(message.Parts)) + 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].Parts = []providertypes.ContentPart{providertypes.NewTextPart(sanitizeProjectedToolContent(providertypes.ExtractTextForProjection(message.Parts)))} + 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 3d3d0a56..372e3c3f 100644 --- a/internal/context/projection_test.go +++ b/internal/context/projection_test.go @@ -39,19 +39,19 @@ func TestProjectToolMessagesForModelSkipsMessagesThatCannotBeProjected(t *testin } projected := ProjectToolMessagesForModel(cloneContextMessages(messages)) - if providertypes.ExtractTextForProjection(projected[0].Parts) != "user" { + if renderDisplayParts(projected[0].Parts) != "user" { t.Fatalf("non-tool message should remain unchanged, got %+v", projected[0]) } - if providertypes.ExtractTextForProjection(projected[1].Parts) != "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(providertypes.ExtractTextForProjection(projected[2].Parts), "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 providertypes.ExtractTextForProjection(projected[3].Parts) != 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(providertypes.ExtractTextForProjection(projected[4].Parts), "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]) } } @@ -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(providertypes.ExtractTextForProjection(recent[1].Parts), "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(providertypes.ExtractTextForProjection(recent[1].Parts), "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(providertypes.ExtractTextForProjection(recent[1].Parts), "content_excerpt:") { + if !strings.Contains(renderDisplayParts(recent[1].Parts), "content_excerpt:") { t.Fatalf("expected tool payload excerpt marker, got %+v", recent[1]) } - if providertypes.ExtractTextForProjection(recent[2].Parts) != "latest-user" { + if renderDisplayParts(recent[2].Parts) != "latest-user" { t.Fatalf("expected latest user anchor to remain, got %+v", recent[2]) } recent[1].Parts = []providertypes.ContentPart{providertypes.NewTextPart("changed")} - if providertypes.ExtractTextForProjection(original[2].Parts) != "README body" { + if renderDisplayParts(original[2].Parts) != "README body" { t.Fatalf("expected original messages to remain unchanged, got %+v", original[2]) } } @@ -171,7 +171,7 @@ func TestBuildRecentMessagesForModelRespectsAbsoluteMessageBudget(t *testing.T) if len(recent) != 2 { t.Fatalf("len(recent) = %d, want 2", len(recent)) } - if providertypes.ExtractTextForProjection(recent[0].Parts) != "old-user" || providertypes.ExtractTextForProjection(recent[1].Parts) != "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) } } diff --git a/internal/memo/auto_extractor_test.go b/internal/memo/auto_extractor_test.go index e770bbee..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 := providertypes.ExtractTextForProjection(messages[len(messages)-1].Parts) + last := renderMemoParts(messages[len(messages)-1].Parts) return []Entry{{Type: TypeProject, Title: last, Content: last, Source: SourceAutoExtract}}, nil }, } @@ -88,14 +88,14 @@ func TestAutoExtractorTrailingRun(t *testing.T) { extractor := &stubMemoExtractor{ extractFn: func(ctx context.Context, messages []providertypes.Message) ([]Entry, error) { - switch providertypes.ExtractTextForProjection(messages[len(messages)-1].Parts) { + switch renderMemoParts(messages[len(messages)-1].Parts) { case "first": firstStarted <- struct{}{} <-releaseFirst case "second": secondStarted <- struct{}{} } - last := providertypes.ExtractTextForProjection(messages[len(messages)-1].Parts) + last := renderMemoParts(messages[len(messages)-1].Parts) return []Entry{{Type: TypeProject, Title: last, Content: last, Source: SourceAutoExtract}}, nil }, } diff --git a/internal/memo/extractor.go b/internal/memo/extractor.go index 6a98be0b..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 = providertypes.ExtractTextForProjection(messages[i].Parts) + lastUserMsg = renderMemoParts(messages[i].Parts) break } } diff --git a/internal/memo/llm_extractor.go b/internal/memo/llm_extractor.go index 9ce6752b..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(providertypes.ExtractTextForProjection(message.Parts)) != "" { + 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 f529292e..acbbea34 100644 --- a/internal/memo/llm_extractor_test.go +++ b/internal/memo/llm_extractor_test.go @@ -227,11 +227,11 @@ func TestLLMExtractorExtractUsesRecentNonToolMessages(t *testing.T) { t.Fatalf("unexpected tool message in extraction context: %#v", message) } } - if providertypes.ExtractTextForProjection(generator.messages[0].Parts) != "user-c" || - providertypes.ExtractTextForProjection(generator.messages[9].Parts) != "user-l" { + if renderMemoParts(generator.messages[0].Parts) != "user-c" || + renderMemoParts(generator.messages[9].Parts) != "user-l" { t.Fatalf("unexpected recent window: first=%q last=%q", - providertypes.ExtractTextForProjection(generator.messages[0].Parts), - providertypes.ExtractTextForProjection(generator.messages[9].Parts)) + renderMemoParts(generator.messages[0].Parts), + renderMemoParts(generator.messages[9].Parts)) } } @@ -294,7 +294,7 @@ func TestLLMExtractorExtractKeepsProjectedToolCallSpan(t *testing.T) { if toolMessage.Role != providertypes.RoleTool { t.Fatalf("expected projected tool message, got %#v", toolMessage) } - toolText := providertypes.ExtractTextForProjection(toolMessage.Parts) + 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) } @@ -331,7 +331,7 @@ func TestLLMExtractorExtractKeepsMetadataOnlyToolCallSpan(t *testing.T) { if toolMessage.Role != providertypes.RoleTool { t.Fatalf("expected projected tool message, got %#v", toolMessage) } - toolText := providertypes.ExtractTextForProjection(toolMessage.Parts) + toolText := renderMemoParts(toolMessage.Parts) if !strings.Contains(toolText, "tool result") || !strings.Contains(toolText, "tool: filesystem_read_file") || !strings.Contains(toolText, "meta.path: README.md") { @@ -410,3 +410,21 @@ func TestExtractJSONArrayErrors(t *testing.T) { t.Fatalf("expected incomplete array error, got %v", err) } } + +func TestLLMExtractorExtractImageOnlyUserMessageTriggersGenerator(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 != 1 { + t.Fatalf("Generate() calls = %d, want 1", generator.calls) + } +} diff --git a/internal/memo/message_clone_test.go b/internal/memo/message_clone_test.go index 59f60904..da2ff121 100644 --- a/internal/memo/message_clone_test.go +++ b/internal/memo/message_clone_test.go @@ -33,7 +33,7 @@ func TestCloneProviderMessageHandlesEmptyCollections(t *testing.T) { t.Parallel() cloned := cloneProviderMessage(providertypes.Message{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}) - if cloned.Role != providertypes.RoleUser || providertypes.ExtractTextForProjection(cloned.Parts) != "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..b9218e5e --- /dev/null +++ b/internal/memo/parts_projection.go @@ -0,0 +1,30 @@ +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 { + 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/memo/parts_projection_test.go b/internal/memo/parts_projection_test.go new file mode 100644 index 00000000..3f9c739c --- /dev/null +++ b/internal/memo/parts_projection_test.go @@ -0,0 +1,30 @@ +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 TestHasMemoRelevantUserInputTreatsImageAsInput(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 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..64db2ba7 --- /dev/null +++ b/internal/partsrender/render.go @@ -0,0 +1,81 @@ +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: + url := strings.TrimSpace(image.URL) + if url == "" { + return "[Image]" + } + if mode == imageRenderModeDisplay { + return "[Image]" + } + return "[Image:remote] " + url + case providertypes.ImageSourceSessionAsset: + if image.Asset == nil { + return "[Image]" + } + assetID := strings.TrimSpace(image.Asset.ID) + mime := strings.TrimSpace(image.Asset.MimeType) + if assetID == "" { + return "[Image]" + } + if mode == imageRenderModeDisplay { + 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..6eaa75e5 --- /dev/null +++ b/internal/partsrender/render_test.go @@ -0,0 +1,70 @@ +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.NewSessionAssetImagePart("asset-2", ""), + } + + got := RenderTranscriptParts(parts) + want := "look[Image:session_asset] asset-2" + 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]") + } +} diff --git a/internal/provider/openaicompat/chatcompletions/provider_test.go b/internal/provider/openaicompat/chatcompletions/provider_test.go index 143b1614..4978ca9d 100644 --- a/internal/provider/openaicompat/chatcompletions/provider_test.go +++ b/internal/provider/openaicompat/chatcompletions/provider_test.go @@ -611,6 +611,48 @@ func TestGenerateVariants(t *testing.T) { } }) + 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() diff --git a/internal/provider/types/content_part.go b/internal/provider/types/content_part.go index 4ab7acec..e2c9e4e2 100644 --- a/internal/provider/types/content_part.go +++ b/internal/provider/types/content_part.go @@ -2,7 +2,6 @@ package types import ( "errors" - "strings" ) // ContentPartKind defines the type of a content part. @@ -122,17 +121,3 @@ func CloneParts(parts []ContentPart) []ContentPart { } return res } - -// ExtractTextForProjection extracts pure text from parts for legacy compatibility. -// TODO(Phase 4): Migrate to proper Part rendering. -func ExtractTextForProjection(parts []ContentPart) string { - var builder strings.Builder - for _, part := range parts { - if part.Kind == ContentPartText { - builder.WriteString(part.Text) - } else if part.Kind == ContentPartImage { - builder.WriteString("[Image]") - } - } - return builder.String() -} 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 bd946395..a4926bd4 100644 --- a/internal/provider/types/types_test.go +++ b/internal/provider/types/types_test.go @@ -172,7 +172,7 @@ func TestMessageStructFields(t *testing.T) { IsError: true, ToolMetadata: map[string]string{"path": "main.go"}, } - if msg.Role != RoleUser || ExtractTextForProjection(msg.Parts) != "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 2213d860..8a68f675 100644 --- a/internal/runtime/compact_generator.go +++ b/internal/runtime/compact_generator.go @@ -100,7 +100,21 @@ func (g *compactSummaryGenerator) Generate( return contextcompact.SummaryOutput{}, errors.New("runtime: compact summary response must not contain tool calls") } - return parseCompactSummaryOutput(providertypes.ExtractTextForProjection(message.Parts)) + 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 726158b1..ebfd65da 100644 --- a/internal/runtime/compact_generator_test.go +++ b/internal/runtime/compact_generator_test.go @@ -92,7 +92,7 @@ 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) } - promptText := providertypes.ExtractTextForProjection(req.Messages[0].Parts) + promptText := renderPartsForTest(req.Messages[0].Parts) if !strings.Contains(promptText, "") { t.Fatalf("expected archived material boundary, got %q", promptText) } 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/run.go b/internal/runtime/run.go index 86d33b1d..174eba9e 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -447,12 +447,29 @@ func validateUserInputParts(parts []providertypes.ContentPart) error { if err := providertypes.ValidateParts(parts); err != nil { return fmt.Errorf("runtime: invalid input parts: %w", err) } - if strings.TrimSpace(providertypes.ExtractTextForProjection(parts)) == "" { + 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 { diff --git a/internal/runtime/runtime_gap_coverage_test.go b/internal/runtime/runtime_gap_coverage_test.go index 58806fd6..548c37a2 100644 --- a/internal/runtime/runtime_gap_coverage_test.go +++ b/internal/runtime/runtime_gap_coverage_test.go @@ -102,7 +102,7 @@ func TestRunCompactForSessionSaveErrorPolicyBranches(t *testing.T) { if err == nil { t.Fatalf("expected strict mode save error") } - if providertypes.ExtractTextForProjection(strictSession.Messages[0].Parts) != "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 providertypes.ExtractTextForProjection(bestEffortSession.Messages[0].Parts) != "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 004bde09..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 providertypes.ExtractTextForProjection(msg.Parts) != "" { - t.Fatalf("expected metadata-only success result to keep empty content, got %q", providertypes.ExtractTextForProjection(msg.Parts)) + 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 providertypes.ExtractTextForProjection(msg.Parts) != "ok" { - t.Fatalf("expected empty success result to be normalized to ok, got %q", providertypes.ExtractTextForProjection(msg.Parts)) + 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 providertypes.ExtractTextForProjection(msg.Parts) != "ok" { - t.Fatalf("expected tool_name-only metadata success to normalize content to ok, got %q", providertypes.ExtractTextForProjection(msg.Parts)) + 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 providertypes.ExtractTextForProjection(state.session.Messages[0].Parts) != toolErr.Error() { - t.Fatalf("expected tool error content fallback, got %q", providertypes.ExtractTextForProjection(state.session.Messages[0].Parts)) + if renderPartsForTest(state.session.Messages[0].Parts) != toolErr.Error() { + t.Fatalf("expected tool error content fallback, got %q", renderPartsForTest(state.session.Messages[0].Parts)) } } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 5f2840d8..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 providertypes.ExtractTextForProjection(response.Message.Parts) != "" { + if renderPartsForTest(response.Message.Parts) != "" { select { - case events <- providertypes.NewTextDeltaStreamEvent(providertypes.ExtractTextForProjection(response.Message.Parts)): + case events <- providertypes.NewTextDeltaStreamEvent(renderPartsForTest(response.Message.Parts)): case <-ctx.Done(): return ctx.Err() } @@ -476,7 +476,7 @@ 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 || providertypes.ExtractTextForProjection(scripted.requests[0].Messages[0].Parts) != "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) } }, @@ -530,10 +530,10 @@ func TestServiceRun(t *testing.T) { for _, message := range second.Messages { if message.Role == "tool" && message.ToolCallID == "call-1" && - strings.Contains(providertypes.ExtractTextForProjection(message.Parts), "tool result") && - strings.Contains(providertypes.ExtractTextForProjection(message.Parts), "tool: filesystem_edit") && - strings.Contains(providertypes.ExtractTextForProjection(message.Parts), "status: ok") && - strings.Contains(providertypes.ExtractTextForProjection(message.Parts), "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 || providertypes.ExtractTextForProjection(session.Messages[2].Parts) != "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" { @@ -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(providertypes.ExtractTextForProjection(message.Parts), "tool result") && - strings.Contains(providertypes.ExtractTextForProjection(message.Parts), "tool: filesystem_read_file") && - strings.Contains(providertypes.ExtractTextForProjection(message.Parts), "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(providertypes.ExtractTextForProjection(message.Parts), "content:\n") { - t.Fatalf("expected metadata-only projection to omit content section, got %q", providertypes.ExtractTextForProjection(message.Parts)) + 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 || providertypes.ExtractTextForProjection(session.Messages[2].Parts) != "" { + 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" || @@ -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 || providertypes.ExtractTextForProjection(builder.lastInput.Messages[0].Parts) != "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 || providertypes.ExtractTextForProjection(scripted.requests[0].Messages[0].Parts) != "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) } } @@ -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 := providertypes.ExtractTextForProjection(scripted.requests[0].Messages[2].Parts); 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) } } @@ -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 := providertypes.ExtractTextForProjection(scripted.requests[0].Messages[2].Parts); 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) } } @@ -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 || providertypes.ExtractTextForProjection(saved.Messages[1].Parts) != "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) } } @@ -1387,7 +1387,7 @@ func TestServiceRunUsesToolManager(t *testing.T) { foundToolMessage := false for _, message := range session.Messages { if message.Role == providertypes.RoleTool && - providertypes.ExtractTextForProjection(message.Parts) == "tool manager output" && + renderPartsForTest(message.Parts) == "tool manager output" && message.ToolMetadata["tool_name"] == "filesystem_edit" && message.ToolMetadata["path"] == "main.go" { foundToolMessage = true @@ -1787,7 +1787,7 @@ 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 providertypes.ExtractTextForProjection(session.Messages[len(session.Messages)-1].Parts) != "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]) } }, @@ -1868,8 +1868,8 @@ func TestServiceRunErrorPaths(t *testing.T) { if len(session.Messages) != 2 { t.Fatalf("expected user + assistant messages, got %d", len(session.Messages)) } - if providertypes.ExtractTextForProjection(session.Messages[1].Parts) != "recovered" { - t.Fatalf("expected assistant content %q, got %q", "recovered", providertypes.ExtractTextForProjection(session.Messages[1].Parts)) + if renderPartsForTest(session.Messages[1].Parts) != "recovered" { + t.Fatalf("expected assistant content %q, got %q", "recovered", renderPartsForTest(session.Messages[1].Parts)) } }, }, @@ -2420,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(providertypes.ExtractTextForProjection(saved.Messages[0].Parts), "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) } @@ -2459,7 +2459,7 @@ func TestServiceCompactManualFailureReturnsError(t *testing.T) { if err != nil { t.Fatalf("load original session: %v", err) } - if len(saved.Messages) != 3 || providertypes.ExtractTextForProjection(saved.Messages[2].Parts) != "before" { + if len(saved.Messages) != 3 || renderPartsForTest(saved.Messages[2].Parts) != "before" { t.Fatalf("expected original session untouched, got %+v", saved.Messages) } @@ -2660,7 +2660,7 @@ func TestServiceManualCompactThenRunContinuesToolRound(t *testing.T) { if err != nil { t.Fatalf("load session: %v", err) } - if len(saved.Messages) < 6 || !strings.Contains(providertypes.ExtractTextForProjection(saved.Messages[0].Parts), "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) } @@ -3651,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 providertypes.ExtractTextForProjection(scripted.requests[0].Messages[0].Parts) != "[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 providertypes.ExtractTextForProjection(scripted.requests[0].Messages[1].Parts) != "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) } @@ -3930,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 providertypes.ExtractTextForProjection(saved.Messages[2].Parts) != "recovered" { - t.Fatalf("expected final assistant reply %q, got %q", "recovered", providertypes.ExtractTextForProjection(saved.Messages[2].Parts)) + 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()) 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/store_test.go b/internal/session/store_test.go index d36f1615..02c2b651 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -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 || providertypes.ExtractTextForProjection(loaded.Messages[1].Parts) != "world" { + if len(loaded.Messages) != 2 || renderPartsForTest(loaded.Messages[1].Parts) != "world" { t.Fatalf("unexpected loaded messages: %+v", loaded.Messages) } @@ -684,8 +684,8 @@ func TestJSONStoreSaveRoundTripsMetadataOnlyToolMessage(t *testing.T) { if err != nil { t.Fatalf("load saved session: %v", err) } - if providertypes.ExtractTextForProjection(loaded.Messages[2].Parts) != "" { - t.Fatalf("expected empty content to round-trip, got %q", providertypes.ExtractTextForProjection(loaded.Messages[2].Parts)) + 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" { diff --git a/internal/tools/format.go b/internal/tools/format.go index 9fbc9462..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(providertypes.ExtractTextForProjection(message.Parts)) != "" { - lines = append(lines, "", "content:", providertypes.ExtractTextForProjection(message.Parts)) + 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/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 bb1b2386..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(providertypes.ExtractTextForProjection(app.activeMessages[len(app.activeMessages)-1].Parts), "[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(providertypes.ExtractTextForProjection(runtime.runInputs[0].Parts), "[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 64472b1c..a58ce7a6 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -410,6 +410,13 @@ 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.clearActivities() a.clearRunProgress() @@ -420,17 +427,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, Parts: []providertypes.ContentPart{providertypes.NewTextPart(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...) } @@ -1082,8 +1084,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(providertypes.ExtractTextForProjection(payload.Parts)) != "" && !a.lastAssistantMatches(providertypes.ExtractTextForProjection(payload.Parts)) { - a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart(providertypes.ExtractTextForProjection(payload.Parts))}}) + 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 @@ -1245,7 +1250,7 @@ func (a *App) appendAssistantChunk(chunk string) { return } - content := providertypes.ExtractTextForProjection(a.activeMessages[len(a.activeMessages)-1].Parts) + content := renderMessagePartsForDisplay(a.activeMessages[len(a.activeMessages)-1].Parts) a.activeMessages[len(a.activeMessages)-1].Parts = []providertypes.ContentPart{providertypes.NewTextPart(content + chunk)} } @@ -1307,7 +1312,7 @@ func (a *App) lastAssistantMatches(content string) bool { } last := a.activeMessages[len(a.activeMessages)-1] - return last.Role == roleAssistant && strings.TrimSpace(providertypes.ExtractTextForProjection(last.Parts)) == 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) { diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index ae3faea7..cf8b6863 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -131,7 +131,7 @@ func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, w } func messageText(message providertypes.Message) string { - return providertypes.ExtractTextForProjection(message.Parts) + return renderMessagePartsForDisplay(message.Parts) } func newDefaultAppConfig() *config.Config { diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 613f7a8c..d689106d 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -223,11 +223,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(providertypes.ExtractTextForProjection(message.Parts), 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(providertypes.ExtractTextForProjection(message.Parts), 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(providertypes.ExtractTextForProjection(message.Parts), 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) @@ -245,7 +245,7 @@ func (a App) renderMessageBlockWithCopy(message providertypes.Message, width int return "", nil } - content := strings.TrimSpace(providertypes.ExtractTextForProjection(message.Parts)) + 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/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 f50a1a15..42b5eec4 100644 --- a/internal/tui/services/services_test.go +++ b/internal/tui/services/services_test.go @@ -97,7 +97,7 @@ func TestRunAgentCmd(t *testing.T) { runner := &stubRunner{err: errors.New("boom")} 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" || providertypes.ExtractTextForProjection(runner.lastInput.Parts) != "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" { From 0232b2c459ebdb6561fe33578bcb57d6b030b0b9 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 16 Apr 2026 11:44:04 +0000 Subject: [PATCH 06/10] fix(provider): tighten multimodal part validation and empty message semantics Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- .../chatcompletions/provider_test.go | 2 +- internal/provider/types/content_part.go | 28 ++++++---- internal/provider/types/content_part_test.go | 5 ++ internal/provider/types/message.go | 14 ++++- internal/provider/types/message_test.go | 54 +++++++++++++++++++ 5 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 internal/provider/types/message_test.go diff --git a/internal/provider/openaicompat/chatcompletions/provider_test.go b/internal/provider/openaicompat/chatcompletions/provider_test.go index 4978ca9d..0b398dec 100644 --- a/internal/provider/openaicompat/chatcompletions/provider_test.go +++ b/internal/provider/openaicompat/chatcompletions/provider_test.go @@ -151,7 +151,7 @@ func TestNewAndBuildRequest(t *testing.T) { {Kind: providertypes.ContentPartImage, Image: &providertypes.ImagePart{SourceType: "unknown"}}, }, }) - if err == nil || !strings.Contains(err.Error(), "unsupported image part payload") { + if err == nil || !strings.Contains(err.Error(), "unsupported source type") { t.Fatalf("expected unsupported image error, got %v", err) } diff --git a/internal/provider/types/content_part.go b/internal/provider/types/content_part.go index e2c9e4e2..1f7bcd71 100644 --- a/internal/provider/types/content_part.go +++ b/internal/provider/types/content_part.go @@ -80,24 +80,34 @@ func NewSessionAssetImagePart(id, mimeType string) ContentPart { // ValidateParts checks if the given parts are valid. func ValidateParts(parts []ContentPart) error { for _, part := range parts { - if part.Kind == ContentPartText { + switch part.Kind { + case ContentPartText: if part.Image != nil { return errors.New("text part cannot contain image payload") } - } else if part.Kind == ContentPartImage { - if part.Image == nil { + case ContentPartImage: + image := part.Image + if image == nil { return errors.New("image part must contain image payload") } - if part.Image.SourceType == ImageSourceRemote && part.Image.URL == "" { - return errors.New("remote image part must contain url") - } - if part.Image.SourceType == ImageSourceSessionAsset && (part.Image.Asset == nil || part.Image.Asset.ID == "") { - return errors.New("session asset image part must contain asset ID") + + switch image.SourceType { + case ImageSourceRemote: + if 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") } - } else { + default: return errors.New("unknown content part kind") } } + return nil } diff --git a/internal/provider/types/content_part_test.go b/internal/provider/types/content_part_test.go index b2ee979b..b9cd4834 100644 --- a/internal/provider/types/content_part_test.go +++ b/internal/provider/types/content_part_test.go @@ -75,6 +75,11 @@ func TestValidateParts(t *testing.T) { 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"}}, diff --git a/internal/provider/types/message.go b/internal/provider/types/message.go index 43e0757e..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" @@ -24,7 +26,17 @@ type Message struct { // IsEmpty checks if the message has no content parts and no tool calls. func (m *Message) IsEmpty() bool { - return len(m.Parts) == 0 && len(m.ToolCalls) == 0 + 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. diff --git a/internal/provider/types/message_test.go b/internal/provider/types/message_test.go new file mode 100644 index 00000000..180b8a7a --- /dev/null +++ b/internal/provider/types/message_test.go @@ -0,0 +1,54 @@ +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) + } + }) + } +} From 26fc9d4ef55b72c40dc21b542b64a496c13fba65 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 16 Apr 2026 13:03:35 +0000 Subject: [PATCH 07/10] fix(multimodal): redact transcript image identifiers and tighten memo gating Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- .../context/compact/transcript_store_test.go | 41 +++++++++++++++++++ internal/memo/llm_extractor_test.go | 6 +-- internal/memo/parts_projection.go | 11 +---- internal/memo/parts_projection_test.go | 12 ++++-- internal/partsrender/render.go | 16 +++++--- internal/partsrender/render_test.go | 5 ++- 6 files changed, 69 insertions(+), 22 deletions(-) diff --git a/internal/context/compact/transcript_store_test.go b/internal/context/compact/transcript_store_test.go index b563af8a..9c79cf53 100644 --- a/internal/context/compact/transcript_store_test.go +++ b/internal/context/compact/transcript_store_test.go @@ -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() diff --git a/internal/memo/llm_extractor_test.go b/internal/memo/llm_extractor_test.go index acbbea34..43d82291 100644 --- a/internal/memo/llm_extractor_test.go +++ b/internal/memo/llm_extractor_test.go @@ -411,7 +411,7 @@ func TestExtractJSONArrayErrors(t *testing.T) { } } -func TestLLMExtractorExtractImageOnlyUserMessageTriggersGenerator(t *testing.T) { +func TestLLMExtractorExtractImageOnlyUserMessageSkipsGenerator(t *testing.T) { generator := &stubTextGenerator{response: `[]`} extractor := NewLLMExtractor(generator) @@ -424,7 +424,7 @@ func TestLLMExtractorExtractImageOnlyUserMessageTriggersGenerator(t *testing.T) if len(entries) != 0 { t.Fatalf("len(entries) = %d, want 0", len(entries)) } - if generator.calls != 1 { - t.Fatalf("Generate() calls = %d, want 1", generator.calls) + if generator.calls != 0 { + t.Fatalf("Generate() calls = %d, want 0", generator.calls) } } diff --git a/internal/memo/parts_projection.go b/internal/memo/parts_projection.go index b9218e5e..eb7cbd04 100644 --- a/internal/memo/parts_projection.go +++ b/internal/memo/parts_projection.go @@ -15,15 +15,8 @@ func renderMemoParts(parts []providertypes.ContentPart) string { // hasMemoRelevantUserInput 判断用户消息是否包含可用于 memo 分析的输入。 func hasMemoRelevantUserInput(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 - } + 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 index 3f9c739c..30735e7f 100644 --- a/internal/memo/parts_projection_test.go +++ b/internal/memo/parts_projection_test.go @@ -18,13 +18,19 @@ func TestRenderMemoPartsUsesImagePlaceholder(t *testing.T) { } } -func TestHasMemoRelevantUserInputTreatsImageAsInput(t *testing.T) { +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 should be treated as meaningful user 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 index 64db2ba7..2281b480 100644 --- a/internal/partsrender/render.go +++ b/internal/partsrender/render.go @@ -51,15 +51,24 @@ func renderImagePlaceholder(image *providertypes.ImagePart, mode imageRenderMode 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]" } - return "[Image:remote] " + url - case providertypes.ImageSourceSessionAsset: + if mode == imageRenderModeTranscript { + return "[Image:session_asset]" + } if image.Asset == nil { return "[Image]" } @@ -68,9 +77,6 @@ func renderImagePlaceholder(image *providertypes.ImagePart, mode imageRenderMode if assetID == "" { return "[Image]" } - if mode == imageRenderModeDisplay { - return "[Image]" - } if mime == "" { return "[Image:session_asset] " + assetID } diff --git a/internal/partsrender/render_test.go b/internal/partsrender/render_test.go index 6eaa75e5..9f17d567 100644 --- a/internal/partsrender/render_test.go +++ b/internal/partsrender/render_test.go @@ -26,12 +26,13 @@ func TestRenderTranscriptParts(t *testing.T) { t.Parallel() parts := []providertypes.ContentPart{ - providertypes.NewTextPart("look"), + providertypes.NewTextPart("look "), + providertypes.NewRemoteImagePart("https://example.com/a.png?token=secret"), providertypes.NewSessionAssetImagePart("asset-2", ""), } got := RenderTranscriptParts(parts) - want := "look[Image:session_asset] asset-2" + want := "look [Image:remote][Image:session_asset]" if got != want { t.Fatalf("RenderTranscriptParts() = %q, want %q", got, want) } From a154b10630e939b2248a0f23c2643493f2ce6bde Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 16 Apr 2026 13:18:18 +0000 Subject: [PATCH 08/10] fix(multimodal): preserve blocked input and validate blank image url Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/provider/types/content_part.go | 3 +- internal/provider/types/content_part_test.go | 5 +++ internal/tui/core/app/update.go | 14 +++--- internal/tui/core/app/update_test.go | 46 ++++++++++++++++++++ 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/internal/provider/types/content_part.go b/internal/provider/types/content_part.go index 1f7bcd71..dd18c127 100644 --- a/internal/provider/types/content_part.go +++ b/internal/provider/types/content_part.go @@ -2,6 +2,7 @@ package types import ( "errors" + "strings" ) // ContentPartKind defines the type of a content part. @@ -93,7 +94,7 @@ func ValidateParts(parts []ContentPart) error { switch image.SourceType { case ImageSourceRemote: - if image.URL == "" { + if strings.TrimSpace(image.URL) == "" { return errors.New("remote image part must contain url") } case ImageSourceSessionAsset: diff --git a/internal/provider/types/content_part_test.go b/internal/provider/types/content_part_test.go index b9cd4834..0d4e45ea 100644 --- a/internal/provider/types/content_part_test.go +++ b/internal/provider/types/content_part_test.go @@ -60,6 +60,11 @@ func TestValidateParts(t *testing.T) { 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}}, diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index a58ce7a6..0e2df1a1 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -338,13 +338,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() @@ -418,6 +411,13 @@ 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() + a.clearActivities() a.clearRunProgress() a.state.IsAgentRunning = true diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index cf8b6863..666a3639 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -1108,6 +1108,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) { From e3271a96ec942f8fee226aa6f0333172c2e09801 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 16 Apr 2026 21:33:45 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/core/app/update_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 5e9df348..1d699894 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -161,6 +161,7 @@ func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, w 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 } From 5b7a8c08e09d5ec5a02759d4c8a94bb9a5565e71 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 16 Apr 2026 13:42:19 +0000 Subject: [PATCH 10/10] test(pr322): fix tui compile break and increase multimodal coverage Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/partsrender/render_test.go | 71 +++++++++++++++ .../provider/streaming/accumulator_test.go | 90 +++++++++++++++++++ internal/provider/types/message_test.go | 30 +++++++ internal/tui/core/app/update_test.go | 5 +- internal/tui/core/app/view_test.go | 14 ++- 5 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 internal/provider/streaming/accumulator_test.go diff --git a/internal/partsrender/render_test.go b/internal/partsrender/render_test.go index 9f17d567..73f233e8 100644 --- a/internal/partsrender/render_test.go +++ b/internal/partsrender/render_test.go @@ -69,3 +69,74 @@ func TestRenderPartsFallbackForUnknownImageSource(t *testing.T) { 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/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/message_test.go b/internal/provider/types/message_test.go index 180b8a7a..6248f562 100644 --- a/internal/provider/types/message_test.go +++ b/internal/provider/types/message_test.go @@ -52,3 +52,33 @@ func TestMessageIsEmpty(t *testing.T) { }) } } + +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/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 1d699894..8ff0d3a5 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -2365,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")}}, }, }, } @@ -2906,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, "") @@ -2925,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_test.go b/internal/tui/core/app/view_test.go index 751049c8..0c965689 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -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")