pluginapi/prompt_test.go
Rama main · réplica de lectura del repositorio original.
package pluginapi
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func toolPrompt() *Prompt {
p := &Prompt{Messages: []Message{
{ID: "m0", Role: RoleSystem, Parts: []Part{{Kind: PartText, Text: "be brief"}}},
{ID: "m1", Role: RoleUser, Parts: []Part{{Kind: PartText, Text: "weather?"}, {Kind: PartImage, URL: "https://x/y.png"}}},
{ID: "m2", Role: RoleAssistant, Parts: []Part{{Kind: PartToolCall, ToolCall: &ToolCall{ID: "call_1", Name: "weather", Arguments: json.RawMessage(`{"city":"Oslo"}`)}}}},
{ID: "m3", Role: RoleTool, ToolCallID: "call_1", Parts: []Part{{Kind: PartToolResult, ToolResult: &ToolResult{CallID: "call_1", Parts: []Part{{Kind: PartText, Text: "rain"}}}}}},
{ID: "m4", Role: RoleUser, Parts: []Part{{Kind: PartText, Text: "thanks"}}},
}}
p.Reset()
return p
}
func TestPromptViews(t *testing.T) {
p := toolPrompt()
last := p.LastUser()
require.NotNil(t, last)
assert.Equal(t, "m4", last.ID)
got := p.Text()
assert.Equal(t, "be brief\nweather?\nthanks", got)
got = p.Text(RoleUser)
assert.Equal(t, "weather?\nthanks", got)
got = p.SystemText()
assert.Equal(t, "be brief", got)
calls := p.ToolCalls()
require.Len(t, calls, 1)
assert.Equal(t, "m2", calls[0].MessageID)
assert.True(t, calls[0].HasResult)
assert.Equal(t, "weather", calls[0].Call.Name)
assert.Len(t, p.NewSince(3), 2, "NewSince(3)")
assert.Nil(t, p.NewSince(99), "NewSince(99)")
assert.Nil(t, p.Message("nope"))
assert.Equal(t, RoleTool, p.Message("m3").Role)
assert.False(t, p.Changes().Dirty)
}
func TestPromptSetText(t *testing.T) {
tests := []struct {
name string
msgID string
partIdx int
wantErr string
}{
{name: "text part", msgID: "m1", partIdx: 0},
{name: "unknown message", msgID: "zz", partIdx: 0, wantErr: "unknown message"},
{name: "part out of range", msgID: "m1", partIdx: 5, wantErr: "no part 5"},
{name: "non-text part", msgID: "m1", partIdx: 1, wantErr: "not text"},
{name: "tool call part", msgID: "m2", partIdx: 0, wantErr: "not text"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := toolPrompt()
err := p.SetText(tt.msgID, tt.partIdx, "new")
if tt.wantErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
assert.False(t, p.Changes().Dirty)
return
}
require.NoError(t, err)
assert.Equal(t, "new", p.Message(tt.msgID).Parts[tt.partIdx].Text)
ch := p.Changes()
assert.True(t, ch.Dirty)
assert.Equal(t, ChangeEdited, ch.Messages[tt.msgID], "changes = %+v", ch)
})
}
}
func TestPromptToolEdits(t *testing.T) {
p := toolPrompt()
err := p.SetToolArguments("m2", "call_1", json.RawMessage(`{"city":"Bergen"}`))
require.NoError(t, err)
got := string(p.Message("m2").Parts[0].ToolCall.Arguments)
assert.Equal(t, `{"city":"Bergen"}`, got)
require.Error(t, p.SetToolArguments("m2", "call_1", json.RawMessage(`{bad`)))
require.Error(t, p.SetToolArguments("m2", "call_9", json.RawMessage(`{}`)))
err = p.SetToolResult("m3", "call_1", []Part{{Kind: PartText, Text: "[redacted]"}})
require.NoError(t, err)
got = p.Message("m3").Text()
assert.Equal(t, "[redacted]", got)
require.Error(t, p.SetToolResult("m3", "call_9", nil))
ch := p.Changes()
assert.Equal(t, ChangeEdited, ch.Messages["m2"])
assert.Equal(t, ChangeEdited, ch.Messages["m3"], "changes = %+v", ch.Messages)
}
func TestPromptInsertAppendRemove(t *testing.T) {
p := toolPrompt()
first := p.Insert(0, TextMessage(RoleSystem, "prefix"))
last := p.Append(TextMessage(RoleUser, "suffix"))
far := p.Insert(99, TextMessage(RoleUser, "clamped"))
assert.Equal(t, "new-1", first)
assert.Equal(t, "new-2", last)
assert.Equal(t, "new-3", far)
assert.Equal(t, first, p.Messages[0].ID)
assert.Equal(t, far, p.Messages[len(p.Messages)-1].ID)
assert.Equal(t, last, p.Messages[len(p.Messages)-2].ID)
ch := p.Changes()
assert.Equal(t, ChangeInserted, ch.Messages[first])
assert.Equal(t, ChangeInserted, ch.Messages[last], "changes = %+v", ch.Messages)
err := // Editing an inserted message keeps it inserted.
p.SetText(first, 0, "prefix2")
require.NoError(t, err)
assert.Equal(t, ChangeInserted, p.Changes().Messages[first])
err = // Removing an inserted message forgets it entirely.
p.Remove(first)
require.NoError(t, err)
_, ok := p.Changes().Messages[first]
assert.False(t, ok)
assert.Nil(t, p.Message(first))
err = // Removing an original records it.
p.Remove("m0")
require.NoError(t, err)
assert.Equal(t, ChangeRemoved, p.Changes().Messages["m0"])
assert.Nil(t, p.Message("m0"))
require.Error(t, p.Remove("m0"))
require.Error(t, p.Remove("zz"))
err = p.Validate()
require.NoError(t, err)
// IDs never collide with ones the host handed out.
q := &Prompt{Messages: []Message{{ID: "new-1", Role: RoleUser}}}
q.Reset()
id := q.Append(TextMessage(RoleUser, "x"))
assert.Equal(t, "new-2", id)
}
func TestPromptRemoveToolPairs(t *testing.T) {
tests := []struct {
name string
order []string
}{
{name: "call then result", order: []string{"m2", "m3"}},
{name: "result then call", order: []string{"m3", "m2"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := toolPrompt()
err := p.Remove(tt.order[0])
var dangling *DanglingToolError
require.ErrorAs(t, err, &dangling)
assert.Equal(t, tt.order[1], dangling.PartnerID)
assert.Equal(t, "call_1", dangling.CallID, "dangling = %+v", dangling)
assert.Nil(t, p.Message(tt.order[0]))
verr := p.Validate()
require.ErrorAs(t, verr, &dangling)
err = p.Remove(dangling.PartnerID)
require.NoError(t, err)
err = p.Validate()
require.NoError(t, err)
ch := p.Changes()
assert.Equal(t, ChangeRemoved, ch.Messages["m2"])
assert.Equal(t, ChangeRemoved, ch.Messages["m3"], "changes = %+v", ch.Messages)
assert.Empty(t, p.ToolCalls())
})
}
}
// Edits counts every edit call, repeats on one message included, so a host
// can tell whether a step edited anything after an earlier step already
// marked the prompt dirty.
func TestPromptChangesEdits(t *testing.T) {
for _, tt := range []struct {
name string
edit func(p *Prompt)
want int
}{
{"untouched", func(*Prompt) {}, 0},
{"one edit", func(p *Prompt) { _ = p.SetText("m1", 0, "a") }, 1},
{"repeated edit of one message", func(p *Prompt) { _ = p.SetText("m1", 0, "a"); _ = p.SetText("m1", 0, "b") }, 2},
{"param", func(p *Prompt) { p.SetParam("max_tokens", 1) }, 1},
{"param set twice", func(p *Prompt) { p.SetParam("max_tokens", 1); p.SetParam("max_tokens", 2) }, 2},
{"insert and remove", func(p *Prompt) {
p.Insert(0, Message{Role: RoleSystem, Parts: []Part{{Kind: PartText, Text: "x"}}})
_ = p.Remove("m4")
}, 2},
{"rejected edits", func(p *Prompt) {
_ = p.SetText("nope", 0, "x")
_ = p.SetText("m1", 9, "x")
_ = p.SetText("m2", 0, "x") // a tool call, not text
_ = p.Remove("nope")
}, 0},
} {
t.Run(tt.name, func(t *testing.T) {
p := toolPrompt()
tt.edit(p)
ch := p.Changes()
require.Equal(t, tt.want, ch.Edits)
require.Equal(t, tt.want > 0, ch.Dirty)
// Changes() is a copy: later edits do not move it, Reset clears it.
_ = p.SetText("m0", 0, "later")
assert.Equal(t, tt.want, ch.Edits)
assert.Equal(t, tt.want+1, p.Changes().Edits)
p.Reset()
got := p.Changes()
assert.Equal(t, 0, got.Edits)
assert.False(t, got.Dirty, "Changes() after Reset = %+v", got)
})
}
}
// Clone is independent of the original: edits on either side, including
// the change tracking, stay on their side, and the copy keeps generating
// fresh IDs.
func TestPromptClone(t *testing.T) {
p := toolPrompt()
_ = p.SetText("m1", 0, "first")
c := p.Clone()
require.Equal(t, p.Text(), c.Text())
require.Equal(t, 1, c.Changes().Edits)
require.Equal(t, ChangeEdited, c.Changes().Messages["m1"], "clone differs from the original: %q, %+v", c.Text(), c.Changes())
_ = p.SetText("m1", 0, "second")
_ = p.SetToolArguments("m2", "call_1", json.RawMessage(`{"city":"Rome"}`))
_ = p.Remove("m4")
assert.Equal(t, "first", c.Messages[1].Text())
require.Len(t, c.Messages, 5)
assert.Equal(t, 1, c.Changes().Edits)
got := string(c.Messages[2].Parts[0].ToolCall.Arguments)
assert.Equal(t, `{"city":"Oslo"}`, got)
id := c.Insert(0, TextMessage(RoleSystem, "x"))
assert.Equal(t, "new-1", id)
assert.Len(t, p.Messages, 4)
assert.Nil(t, p.Message(id))
assert.Equal(t, "new-1", p.Clone().Insert(0, TextMessage(RoleSystem, "y")))
assert.Empty(t, c.Changes().Messages["m4"], "removal on the original reached the clone")
assert.Empty(t, c.removed, "removal on the original reached the clone")
assert.Nil(t, (*Prompt)(nil).Clone())
// Tool-call arguments are their own bytes on each side.
c = p.Clone()
c.Messages[2].Parts[0].ToolCall.Arguments[2] = 'X'
got = string(p.Messages[2].Parts[0].ToolCall.Arguments)
assert.Equal(t, `{"city":"Rome"}`, got)
// The extra parameters are copied, nested values included.
p.Params.Extra = map[string]any{"metadata": map[string]any{"team": "a"}}
c = p.Clone()
p.Params.Extra["metadata"].(map[string]any)["team"] = "b"
p.Params.Extra["new"] = true
metadata, ok := c.Params.Extra["metadata"].(map[string]any)
require.True(t, ok, "clone metadata = %#v, want an object", c.Params.Extra["metadata"])
assert.Equal(t, "a", metadata["team"], "clone shares the original's extra parameters: %v", c.Params.Extra)
assert.Nil(t, c.Params.Extra["new"], "clone shares the original's extra parameters: %v", c.Params.Extra)
// Object-valued parameters are copied too, not shared.
p.Params.ToolChoice = map[string]any{"type": "function", "function": map[string]any{"name": "weather"}}
c = p.Clone()
p.Params.ToolChoice.(map[string]any)["function"].(map[string]any)["name"] = "changed"
choice, ok := c.Params.ToolChoice.(map[string]any)
require.True(t, ok, "clone tool_choice = %#v, want an object", c.Params.ToolChoice)
function, ok := choice["function"].(map[string]any)
require.True(t, ok, "clone tool_choice function = %#v, want an object", choice["function"])
assert.Equal(t, "weather", function["name"], "clone tool_choice shares the original's map")
}
func TestPromptSetParam(t *testing.T) {
p := toolPrompt()
p.SetParam("max_tokens", 42)
p.SetParam("temperature", 0.5)
p.SetParam("top_p", json.Number("0.9"))
p.SetParam("user", "alice")
require.NotNil(t, p.Params.MaxTokens)
assert.Equal(t, 42, *p.Params.MaxTokens)
require.NotNil(t, p.Params.Temperature)
assert.Equal(t, 0.5, *p.Params.Temperature)
require.NotNil(t, p.Params.TopP)
assert.Equal(t, 0.9, *p.Params.TopP)
ch := p.Changes()
assert.True(t, ch.Dirty)
require.Len(t, ch.Params, 4)
assert.Equal(t, "alice", ch.Params["user"], "changes = %+v", ch)
p.Reset()
assert.False(t, p.Changes().Dirty)
// Changes() returns a copy.
p.SetParam("x", 1)
ch = p.Changes()
ch.Params["y"] = 2
_, ok := p.Changes().Params["y"]
assert.False(t, ok)
}
func TestValuesNilSafe(t *testing.T) {
var v Values
_, ok := v.Get("k")
assert.False(t, ok)
v = Values{}
v.Set("k", 1)
got, ok := v.Get("k")
assert.True(t, ok)
assert.Equal(t, 1, got)
}