diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index e74347e5..db09a6f9 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -207,7 +207,7 @@ func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { } // checkSystemInstructionsWithSigningMode keeps the top-level system in Claude -// Code's minimal CLI shape. A caller's complete system text is preserved as a +// Code's minimal CLI shape. Each caller system block is preserved as a separate // mid-conversation system message after the first user turn, where supported // Claude models give it operator-level authority without changing the cached // top-level prefix. @@ -227,25 +227,25 @@ func checkSystemInstructionsWithSigningModeAt(payload []byte, strictMode bool, c return injectClaudeCodeCurrentDate(payload, now) } - forwardedSystem := collectForwardedClaudeSystemPrompt(system) - if strings.TrimSpace(forwardedSystem) == "" { + forwardedSystemBlocks := collectForwardedClaudeSystemPromptBlocks(system) + if len(forwardedSystemBlocks) == 0 { return injectClaudeCodeCurrentDate(payload, now) } if claudeUsesLegacySystemReminder(payload) { - payload = prependClaudeSystemReminderToFirstUserMessage(payload, forwardedSystem) + payload = prependClaudeSystemRemindersToFirstUserMessage(payload, forwardedSystemBlocks) } else { // Unknown and future model IDs optimistically use the authoritative // mid-conversation system role. Only empirically unsupported legacy IDs // stay on the user-reminder compatibility path. - payload = insertClaudeMidConversationSystemMessage(payload, forwardedSystem) + payload = insertClaudeMidConversationSystemMessages(payload, forwardedSystemBlocks) } return injectClaudeCodeCurrentDate(payload, now) } // relocateClaudeSystemPromptForCountTokens keeps a cloaked count_tokens request // in Claude Code's measured shape, which carries only model, messages and tools. -// The Claude Code system blocks are therefore not installed here, but a caller's -// system prompt still has to be accounted for, so it is relocated into messages +// The Claude Code system blocks are therefore not installed here, but each caller +// system block still has to be accounted for, so it is relocated into messages // using the same positional mapping as the Messages path. That keeps the counted // tokens aligned with the request the caller is about to send while preventing a // third-party system prompt from reaching Anthropic in the system slot. @@ -256,22 +256,22 @@ func relocateClaudeSystemPromptForCountTokens(payload []byte, strictMode bool) [ } // Strict mode drops caller prompts on the Messages path, so it must not // reintroduce them here either. - forwardedSystem := "" + var forwardedSystemBlocks []string if !strictMode { - forwardedSystem = collectForwardedClaudeSystemPrompt(system) + forwardedSystemBlocks = collectForwardedClaudeSystemPromptBlocks(system) } updated, errDelete := sjson.DeleteBytes(payload, "system") if errDelete != nil { return payload } payload = updated - if strings.TrimSpace(forwardedSystem) == "" { + if len(forwardedSystemBlocks) == 0 { return payload } if claudeUsesLegacySystemReminder(payload) { - return prependClaudeSystemReminderToFirstUserMessage(payload, forwardedSystem) + return prependClaudeSystemRemindersToFirstUserMessage(payload, forwardedSystemBlocks) } - return insertClaudeMidConversationSystemMessage(payload, forwardedSystem) + return insertClaudeMidConversationSystemMessages(payload, forwardedSystemBlocks) } // claudeLegacySystemReminderModels lists the official Anthropic model IDs and @@ -309,13 +309,13 @@ func claudeUsesLegacySystemReminder(payload []byte) bool { return legacy } -func collectForwardedClaudeSystemPrompt(system gjson.Result) string { - var parts []string +func collectForwardedClaudeSystemPromptBlocks(system gjson.Result) []string { + var blocks []string appendText := func(text string) { if strings.TrimSpace(text) == "" || util.IsClaudeCodeAttributionSystemText(text) || text == claudeCodeCLIIdentity { return } - parts = append(parts, text) + blocks = append(blocks, text) } if system.IsArray() { @@ -328,7 +328,7 @@ func collectForwardedClaudeSystemPrompt(system gjson.Result) string { } else if system.Type == gjson.String { appendText(system.String()) } - return strings.Join(parts, "\n\n") + return blocks } // buildTextBlock constructs a JSON text block with JSON.stringify-compatible @@ -354,42 +354,62 @@ func marshalJSONStringWithoutHTMLEscape(value string) string { return strings.TrimSuffix(encoded.String(), "\n") } -func prependClaudeSystemReminderToFirstUserMessage(payload []byte, text string) []byte { +func prependClaudeSystemRemindersToFirstUserMessage(payload []byte, texts []string) []byte { firstUserIdx := firstClaudeUserMessageIndex(payload) - if firstUserIdx < 0 { + if firstUserIdx < 0 || len(texts) == 0 { return payload } - reminderText := claudeCallerSystemReminder(text) - reminderBlock := buildTextBlock(reminderText, nil) + reminderTexts := make([]string, 0, len(texts)) + for _, text := range texts { + reminderTexts = append(reminderTexts, claudeCallerSystemReminder(text)) + } + contentPath := fmt.Sprintf("messages.%d.content", firstUserIdx) content := gjson.GetBytes(payload, contentPath) if content.IsArray() { blocks := content.Array() + existing := make(map[string]int, len(blocks)) for _, block := range blocks { - if block.Get("type").String() == "text" && block.Get("text").String() == reminderText { - return payload + if block.Get("type").String() == "text" { + existing[block.Get("text").String()]++ + } + } + + reminderBlocks := make([]string, 0, len(reminderTexts)) + for _, reminderText := range reminderTexts { + if existing[reminderText] > 0 { + existing[reminderText]-- + continue } + reminderBlocks = append(reminderBlocks, buildTextBlock(reminderText, nil)) + } + if len(reminderBlocks) == 0 { + return payload } insertAt := 0 for insertAt < len(blocks) && blocks[insertAt].Get("type").String() == "tool_result" { insertAt++ } - rawBlocks := make([]string, 0, len(blocks)+1) + rawBlocks := make([]string, 0, len(blocks)+len(reminderBlocks)) for idx, block := range blocks { if idx == insertAt { - rawBlocks = append(rawBlocks, reminderBlock) + rawBlocks = append(rawBlocks, reminderBlocks...) } rawBlocks = append(rawBlocks, block.Raw) } if insertAt == len(blocks) { - rawBlocks = append(rawBlocks, reminderBlock) + rawBlocks = append(rawBlocks, reminderBlocks...) } payload, _ = sjson.SetRawBytes(payload, contentPath, []byte("["+strings.Join(rawBlocks, ",")+"]")) } else if content.Type == gjson.String { - userBlock := buildTextBlock(content.String(), nil) - payload, _ = sjson.SetRawBytes(payload, contentPath, []byte("["+reminderBlock+","+userBlock+"]")) + rawBlocks := make([]string, 0, len(reminderTexts)+1) + for _, reminderText := range reminderTexts { + rawBlocks = append(rawBlocks, buildTextBlock(reminderText, nil)) + } + rawBlocks = append(rawBlocks, buildTextBlock(content.String(), nil)) + payload, _ = sjson.SetRawBytes(payload, contentPath, []byte("["+strings.Join(rawBlocks, ",")+"]")) } return payload } @@ -405,9 +425,9 @@ func claudeCallerSystemReminder(text string) string { return reminder.String() } -func insertClaudeMidConversationSystemMessage(payload []byte, text string) []byte { +func insertClaudeMidConversationSystemMessages(payload []byte, texts []string) []byte { firstUserIdx := firstClaudeUserMessageIndex(payload) - if firstUserIdx < 0 { + if firstUserIdx < 0 || len(texts) == 0 { return payload } @@ -415,28 +435,39 @@ func insertClaudeMidConversationSystemMessage(payload []byte, text string) []byt if !messages.IsArray() { return payload } - for _, message := range messages.Array() { - if message.Get("role").String() == "system" && claudeMessageContentText(message.Get("content")) == text { - return payload - } - } - - content := "[" + buildTextBlock(text, map[string]string{"type": "ephemeral"}) + "]" - systemMessage := `{"role":"system","content":` + content + "}" messageBlocks := messages.Array() insertAt := firstUserIdx + 1 for insertAt < len(messageBlocks) && messageBlocks[insertAt].Get("role").String() == "user" { insertAt++ } - rawMessages := make([]string, 0, len(messageBlocks)+1) + if len(messageBlocks)-insertAt >= len(texts) { + matches := true + for idx, text := range texts { + message := messageBlocks[insertAt+idx] + if message.Get("role").String() != "system" || claudeMessageContentText(message.Get("content")) != text { + matches = false + break + } + } + if matches { + return payload + } + } + + systemMessages := make([]string, 0, len(texts)) + for _, text := range texts { + content := "[" + buildTextBlock(text, map[string]string{"type": "ephemeral"}) + "]" + systemMessages = append(systemMessages, `{"role":"system","content":`+content+"}") + } + rawMessages := make([]string, 0, len(messageBlocks)+len(systemMessages)) for idx, message := range messageBlocks { if idx == insertAt { - rawMessages = append(rawMessages, systemMessage) + rawMessages = append(rawMessages, systemMessages...) } rawMessages = append(rawMessages, message.Raw) } if insertAt == len(messageBlocks) { - rawMessages = append(rawMessages, systemMessage) + rawMessages = append(rawMessages, systemMessages...) } payload, _ = sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(rawMessages, ",")+"]")) return payload @@ -578,22 +609,9 @@ func injectClaudeCodeCurrentDate(payload []byte, now time.Time) []byte { rawBlocks = append(rawBlocks, block.Raw) } - insertAt := 0 - for insertAt < len(rawBlocks) { - block := gjson.Parse(rawBlocks[insertAt]) - if block.Get("type").String() == "tool_result" { - insertAt++ - continue - } - if block.Get("type").String() == "text" && isClaudeCodeContextReminder(block.Get("text").String()) { - insertAt++ - continue - } - break - } rawBlocks = append(rawBlocks, "") - copy(rawBlocks[insertAt+1:], rawBlocks[insertAt:]) - rawBlocks[insertAt] = dateBlock + copy(rawBlocks[1:], rawBlocks) + rawBlocks[0] = dateBlock payload, _ = sjson.SetRawBytes(payload, contentPath, []byte("["+strings.Join(rawBlocks, ",")+"]")) return payload } diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index d8c4448d..d27c4991 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -3639,24 +3639,29 @@ func assertClaudeLegacySystemReminderLayout(t *testing.T, body []byte, wantSyste } content := gjson.GetBytes(body, "messages.0.content").Array() if len(content) != 3 { - t.Fatalf("user content has %d blocks, want caller reminder, currentDate, and user text", len(content)) + t.Fatalf("user content has %d blocks, want currentDate, caller reminder, and user text", len(content)) } - if got := content[0].Get("text").String(); got != claudeCallerSystemReminder(wantSystem) { + assertClaudeCodeCurrentDateBlock(t, content[0]) + if got := content[1].Get("text").String(); got != claudeCallerSystemReminder(wantSystem) { t.Fatalf("caller reminder lost system prompt: got len %d, want len %d", len(got), len(wantSystem)) } - if content[0].Get("cache_control").Exists() { - t.Fatalf("caller reminder unexpectedly has cache_control: %s", content[0].Raw) + if content[1].Get("cache_control").Exists() { + t.Fatalf("caller reminder unexpectedly has cache_control: %s", content[1].Raw) } - assertClaudeCodeCurrentDateBlock(t, content[1]) assertEphemeralUserTextBlock(t, content[2], wantUser) } func assertClaudeCodeCurrentDateBlock(t *testing.T, block gjson.Result) { + t.Helper() + assertClaudeCodeCurrentDateBlockAt(t, block, time.Now()) +} + +func assertClaudeCodeCurrentDateBlockAt(t *testing.T, block gjson.Result, now time.Time) { t.Helper() if got := block.Get("type").String(); got != "text" { t.Fatalf("currentDate block type = %q, want text", got) } - if got, want := block.Get("text").String(), claudeCodeCurrentDateReminder(time.Now()); got != want { + if got, want := block.Get("text").String(), claudeCodeCurrentDateReminder(now); got != want { t.Fatalf("currentDate reminder = %q, want %q", got, want) } if block.Get("cache_control").Exists() { @@ -3753,29 +3758,38 @@ func TestInjectClaudeCodeCurrentDateIsIdempotentAndAlignsFirstUserCache(t *testi assertEphemeralUserTextBlock(t, content[1], "hello") } -func TestInjectClaudeCodeCurrentDateFollowsLeadingRemindersAndToolResults(t *testing.T) { +func TestInjectClaudeCodeCurrentDateMovesExistingCopyToFirstBlock(t *testing.T) { + fixed := time.Date(2026, time.August, 1, 9, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + dateBlock := buildTextBlock(claudeCodeCurrentDateReminder(fixed), nil) + payload := []byte(`{"messages":[{"role":"user","content":[` + + `{"type":"text","text":"hello"},` + dateBlock + `]}]}`) + + out := injectClaudeCodeCurrentDate(payload, fixed) + content := gjson.GetBytes(out, "messages.0.content").Array() + if len(content) != 2 { + t.Fatalf("content has %d blocks, want one currentDate and user text: %s", len(content), out) + } + assertClaudeCodeCurrentDateBlockAt(t, content[0], fixed) + assertEphemeralUserTextBlock(t, content[1], "hello") +} + +func TestInjectClaudeCodeCurrentDatePrecedesExistingReminder(t *testing.T) { fixed := time.Date(2026, time.August, 1, 9, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) reminder := "\ncaller instructions\n" payload := []byte(`{"messages":[{"role":"user","content":[` + - `{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"},` + buildTextBlock(reminder, nil) + `,` + `{"type":"text","text":"continue","cache_control":{"type":"ephemeral","ttl":"1h"}}]}]}`) out := injectClaudeCodeCurrentDate(payload, fixed) content := gjson.GetBytes(out, "messages.0.content").Array() - if len(content) != 4 { - t.Fatalf("content has %d blocks, want tool_result, reminder, currentDate, user text: %s", len(content), out) - } - if got := content[0].Get("type").String(); got != "tool_result" { - t.Fatalf("content[0].type = %q, want tool_result", got) + if len(content) != 3 { + t.Fatalf("content has %d blocks, want currentDate, reminder, and user text: %s", len(content), out) } + assertClaudeCodeCurrentDateBlockAt(t, content[0], fixed) if got := content[1].Get("text").String(); got != reminder { t.Fatalf("content[1].text = %q, want standalone reminder", got) } - if got := content[2].Get("text").String(); got != claudeCodeCurrentDateReminder(fixed) { - t.Fatalf("content[2].text = %q, want currentDate after reminder", got) - } - assertEphemeralUserTextBlock(t, content[3], "continue") + assertEphemeralUserTextBlock(t, content[2], "continue") } // Test case 1: String system prompt becomes an authoritative mid-conversation @@ -3865,18 +3879,42 @@ func TestCheckSystemInstructionsWithMode_LegacyModelUsesSystemReminder(t *testin } content := gjson.GetBytes(out, "messages.0.content").Array() if len(content) != 3 { - t.Fatalf("user content has %d blocks, want caller reminder, currentDate, and user text", len(content)) + t.Fatalf("user content has %d blocks, want currentDate, caller reminder, and user text", len(content)) } - if got := content[0].Get("text").String(); got != claudeCallerSystemReminder("legacy instructions") { + assertClaudeCodeCurrentDateBlock(t, content[0]) + if got := content[1].Get("text").String(); got != claudeCallerSystemReminder("legacy instructions") { t.Fatalf("caller system reminder = %q", got) } - if content[0].Get("cache_control").Exists() { - t.Fatalf("caller system reminder unexpectedly has cache_control: %s", content[0].Raw) + if content[1].Get("cache_control").Exists() { + t.Fatalf("caller system reminder unexpectedly has cache_control: %s", content[1].Raw) } - assertClaudeCodeCurrentDateBlock(t, content[1]) assertEphemeralUserTextBlock(t, content[2], "hi") } +func TestCheckSystemInstructionsWithMode_LegacyModelKeepsSystemBlocksSeparate(t *testing.T) { + payload := []byte(`{"model":"claude-opus-4-6","system":[` + + `{"type":"text","text":"first guidance","cache_control":{"type":"ephemeral","ttl":"1h"}},` + + `{"type":"text","text":"second guidance"}],` + + `"messages":[{"role":"user","content":"hi"}]}`) + + out := checkSystemInstructionsWithMode(payload, false) + content := gjson.GetBytes(out, "messages.0.content").Array() + if len(content) != 4 { + t.Fatalf("user content has %d blocks, want currentDate, two caller reminders, and user text: %s", len(content), out) + } + assertClaudeCodeCurrentDateBlock(t, content[0]) + for idx, want := range []string{"first guidance", "second guidance"} { + block := content[idx+1] + if got := block.Get("text").String(); got != claudeCallerSystemReminder(want) { + t.Fatalf("content[%d].text = %q, want separate caller reminder %q", idx+1, got, want) + } + if block.Get("cache_control").Exists() { + t.Fatalf("content[%d] caller reminder unexpectedly has cache_control: %s", idx+1, block.Raw) + } + } + assertEphemeralUserTextBlock(t, content[3], "hi") +} + // Test case 2: Strict mode keeps only the injected Claude Code system blocks. func TestCheckSystemInstructionsWithMode_StringSystemStrict(t *testing.T) { payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`) @@ -3932,6 +3970,72 @@ func TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) { assertClaudeMidConversationSystemMessage(t, out, 1, "Be concise.") } +func TestCheckSystemInstructionsWithMode_ArraySystemKeepsBlocksAsSeparateMessages(t *testing.T) { + payload := []byte(`{"model":"claude-opus-5","system":[` + + `{"type":"text","text":"first guidance","cache_control":{"type":"ephemeral","ttl":"1h"}},` + + `{"type":"text","text":"second guidance"}],` + + `"messages":[{"role":"user","content":"hi"}]}`) + + out := checkSystemInstructionsWithMode(payload, false) + if got := gjson.GetBytes(out, "messages.#").Int(); got != 3 { + t.Fatalf("message count = %d, want user and two separate system messages: %s", got, out) + } + content := gjson.GetBytes(out, "messages.0.content").Array() + if len(content) != 2 { + t.Fatalf("user content has %d blocks, want currentDate and user text: %s", len(content), out) + } + assertClaudeCodeCurrentDateBlock(t, content[0]) + assertEphemeralUserTextBlock(t, content[1], "hi") + assertClaudeMidConversationSystemMessage(t, out, 1, "first guidance") + assertClaudeMidConversationSystemMessage(t, out, 2, "second guidance") +} + +func TestRelocateClaudeSystemPromptForCountTokensKeepsBlocksSeparate(t *testing.T) { + tests := []struct { + name string + model string + legacy bool + }{ + {name: "mid-system model", model: "claude-opus-5"}, + {name: "legacy model", model: "claude-opus-4-6", legacy: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + payload := []byte(`{"model":"` + test.model + `","system":[` + + `{"type":"text","text":"first guidance"},` + + `{"type":"text","text":"second guidance"}],` + + `"messages":[{"role":"user","content":"hi"}]}`) + + out := relocateClaudeSystemPromptForCountTokens(payload, false) + if gjson.GetBytes(out, "system").Exists() { + t.Fatalf("count_tokens system must be absent: %s", out) + } + if test.legacy { + content := gjson.GetBytes(out, "messages.0.content").Array() + if len(content) != 3 { + t.Fatalf("legacy content has %d blocks, want two reminders and user text: %s", len(content), out) + } + if got := content[0].Get("text").String(); got != claudeCallerSystemReminder("first guidance") { + t.Fatalf("first caller reminder = %q", got) + } + if got := content[1].Get("text").String(); got != claudeCallerSystemReminder("second guidance") { + t.Fatalf("second caller reminder = %q", got) + } + if got := content[2].Get("text").String(); got != "hi" { + t.Fatalf("user text = %q, want hi", got) + } + return + } + if got := gjson.GetBytes(out, "messages.#").Int(); got != 3 { + t.Fatalf("message count = %d, want user and two system messages: %s", got, out) + } + assertClaudeMidConversationSystemMessage(t, out, 1, "first guidance") + assertClaudeMidConversationSystemMessage(t, out, 2, "second guidance") + }) + } +} + // Test case 5: Special characters survive the mid-conversation system move. func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) { payload := []byte(`{"model":"claude-opus-5","system":"Use tags & \"quotes\" in output.","messages":[{"role":"user","content":"hi"}]}`) @@ -4925,42 +5029,42 @@ func TestClaudeExecutor_ExecuteStreamOAuthCustomToolMCPAliasRoundTrip(t *testing } } -func TestPrependClaudeSystemReminder_FollowsToolResultsAndIsIdempotent(t *testing.T) { - fixed := time.Date(2026, time.August, 1, 9, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) +func TestPrependClaudeSystemReminders_FollowsToolResultsAndIsIdempotent(t *testing.T) { payload := []byte(`{"messages":[` + `{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{}}]},` + `{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"},{"type":"text","text":"continue"}]}` + `]}`) - first := prependClaudeSystemReminderToFirstUserMessage(payload, "legacy guidance") - second := prependClaudeSystemReminderToFirstUserMessage(first, "legacy guidance") + texts := []string{"first guidance", "second guidance"} + first := prependClaudeSystemRemindersToFirstUserMessage(payload, texts) + second := prependClaudeSystemRemindersToFirstUserMessage(first, texts) if !bytes.Equal(first, second) { t.Fatalf("caller reminder insertion is not idempotent:\nfirst: %s\nsecond: %s", first, second) } - out := injectClaudeCodeCurrentDate(first, fixed) - content := gjson.GetBytes(out, "messages.1.content").Array() + content := gjson.GetBytes(first, "messages.1.content").Array() if len(content) != 4 { - t.Fatalf("content has %d blocks, want tool_result, caller reminder, currentDate, and user text", len(content)) + t.Fatalf("content has %d blocks, want tool_result, two caller reminders, and user text", len(content)) } if got := content[0].Get("type").String(); got != "tool_result" { t.Fatalf("content[0].type = %q, want tool_result", got) } - if got := content[1].Get("text").String(); got != claudeCallerSystemReminder("legacy guidance") { - t.Fatalf("content[1].text = %q, want caller reminder", got) + for idx, text := range texts { + if got := content[idx+1].Get("text").String(); got != claudeCallerSystemReminder(text) { + t.Fatalf("content[%d].text = %q, want caller reminder %q", idx+1, got, text) + } } - if got := content[2].Get("text").String(); got != claudeCodeCurrentDateReminder(fixed) { - t.Fatalf("content[2].text = %q, want currentDate", got) + if got := content[3].Get("text").String(); got != "continue" { + t.Fatalf("content[3].text = %q, want user text", got) } - assertEphemeralUserTextBlock(t, content[3], "continue") } -func TestInsertClaudeMidConversationSystemMessage_FollowsToolResultUserTurn(t *testing.T) { +func TestInsertClaudeMidConversationSystemMessages_FollowsToolResultUserTurn(t *testing.T) { payload := []byte(`{"messages":[` + `{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{}}]},` + `{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]}` + `]}`) - out := insertClaudeMidConversationSystemMessage(payload, "guidance") + out := insertClaudeMidConversationSystemMessages(payload, []string{"guidance"}) if got := gjson.GetBytes(out, "messages.#").Int(); got != 3 { t.Fatalf("message count = %d, want 3: %s", got, out) } @@ -4974,14 +5078,14 @@ func TestInsertClaudeMidConversationSystemMessage_FollowsToolResultUserTurn(t *t assertClaudeMidConversationSystemMessage(t, out, 2, "guidance") } -func TestInsertClaudeMidConversationSystemMessage_PrecedesExistingAssistantTurn(t *testing.T) { +func TestInsertClaudeMidConversationSystemMessages_PrecedesExistingAssistantTurn(t *testing.T) { payload := []byte(`{"messages":[` + `{"role":"user","content":"hello"},` + `{"role":"assistant","content":"answer"},` + `{"role":"user","content":"continue"}` + `]}`) - out := insertClaudeMidConversationSystemMessage(payload, "guidance") + out := insertClaudeMidConversationSystemMessages(payload, []string{"guidance"}) roles := gjson.GetBytes(out, "messages.#.role").Array() wantRoles := []string{"user", "system", "assistant", "user"} if len(roles) != len(wantRoles) { @@ -4995,14 +5099,14 @@ func TestInsertClaudeMidConversationSystemMessage_PrecedesExistingAssistantTurn( assertClaudeMidConversationSystemMessage(t, out, 1, "guidance") } -func TestInsertClaudeMidConversationSystemMessage_FollowsConsecutiveUserRun(t *testing.T) { +func TestInsertClaudeMidConversationSystemMessages_FollowsConsecutiveUserRun(t *testing.T) { payload := []byte(`{"messages":[` + `{"role":"user","content":"first"},` + `{"role":"user","content":"second"},` + `{"role":"assistant","content":"answer"}` + `]}`) - out := insertClaudeMidConversationSystemMessage(payload, "guidance") + out := insertClaudeMidConversationSystemMessages(payload, []string{"guidance"}) roles := gjson.GetBytes(out, "messages.#.role").Array() wantRoles := []string{"user", "user", "system", "assistant"} if len(roles) != len(wantRoles) { @@ -5016,13 +5120,19 @@ func TestInsertClaudeMidConversationSystemMessage_FollowsConsecutiveUserRun(t *t assertClaudeMidConversationSystemMessage(t, out, 2, "guidance") } -func TestInsertClaudeMidConversationSystemMessage_IsIdempotent(t *testing.T) { +func TestInsertClaudeMidConversationSystemMessages_IsIdempotent(t *testing.T) { payload := []byte(`{"messages":[{"role":"user","content":"hello"}]}`) - first := insertClaudeMidConversationSystemMessage(payload, "guidance") - second := insertClaudeMidConversationSystemMessage(first, "guidance") + texts := []string{"first guidance", "second guidance"} + first := insertClaudeMidConversationSystemMessages(payload, texts) + second := insertClaudeMidConversationSystemMessages(first, texts) if !bytes.Equal(first, second) { t.Fatalf("mid-conversation system insertion is not idempotent:\nfirst: %s\nsecond: %s", first, second) } + if got := gjson.GetBytes(first, "messages.#").Int(); got != 3 { + t.Fatalf("message count = %d, want user and two system messages: %s", got, first) + } + assertClaudeMidConversationSystemMessage(t, first, 1, texts[0]) + assertClaudeMidConversationSystemMessage(t, first, 2, texts[1]) } // TestClaudeCodeCLIBetas_MatchesObservedClientMatrix pins the Anthropic-Beta