mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(chat,seahorse): persist and display model_name across history (#2897)
* feat(chat,seahorse): persist and display model_name across history * test(seahorse): fix lint regressions in repair coverage * fix(pico): preserve model_name in live updates * fix(pico): preserve model_name through live stream wrappers
This commit is contained in:
@@ -20,6 +20,17 @@ type MessageEditor interface {
|
||||
EditMessage(ctx context.Context, chatID string, messageID string, content string) error
|
||||
}
|
||||
|
||||
// MessageEditorWithPayload extends MessageEditor for channels that can update
|
||||
// structured message metadata in addition to plain text content.
|
||||
type MessageEditorWithPayload interface {
|
||||
EditMessageWithPayload(
|
||||
ctx context.Context,
|
||||
chatID string,
|
||||
messageID string,
|
||||
payload map[string]any,
|
||||
) error
|
||||
}
|
||||
|
||||
// MessageDeleter — channels that can delete a message by ID.
|
||||
type MessageDeleter interface {
|
||||
DeleteMessage(ctx context.Context, chatID string, messageID string) error
|
||||
|
||||
+63
-2
@@ -191,6 +191,19 @@ func outboundMessageBypassesPlaceholderEdit(msg bus.OutboundMessage) bool {
|
||||
return strings.EqualFold(kind, "thought") || strings.EqualFold(kind, "tool_calls")
|
||||
}
|
||||
|
||||
func outboundMessageEditPayload(msg bus.OutboundMessage, content string) map[string]any {
|
||||
payload := map[string]any{
|
||||
"content": content,
|
||||
}
|
||||
if len(msg.Context.Raw) == 0 {
|
||||
return payload
|
||||
}
|
||||
if modelName := strings.TrimSpace(msg.Context.Raw["model_name"]); modelName != "" {
|
||||
payload["model_name"] = modelName
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func outboundMediaChannel(msg bus.OutboundMediaMessage) string {
|
||||
return msg.Context.Channel
|
||||
}
|
||||
@@ -394,7 +407,16 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
|
||||
if deleter, ok := ch.(MessageDeleter); ok {
|
||||
deleter.DeleteMessage(ctx, chatID, entry.id) // best effort
|
||||
} else if editor, ok := ch.(MessageEditor); ok {
|
||||
editor.EditMessage(ctx, chatID, entry.id, msg.Content) // fallback
|
||||
if payloadEditor, ok := ch.(MessageEditorWithPayload); ok {
|
||||
_ = payloadEditor.EditMessageWithPayload(
|
||||
ctx,
|
||||
chatID,
|
||||
entry.id,
|
||||
outboundMessageEditPayload(msg, msg.Content),
|
||||
)
|
||||
} else {
|
||||
editor.EditMessage(ctx, chatID, entry.id, msg.Content) // fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,7 +468,18 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
|
||||
trackedContent = prepareToolFeedbackMessageContent(ch, msg.Content)
|
||||
content = InitialAnimatedToolFeedbackContent(trackedContent)
|
||||
}
|
||||
if err := editor.EditMessage(ctx, chatID, entry.id, content); err == nil {
|
||||
err := func() error {
|
||||
if payloadEditor, ok := ch.(MessageEditorWithPayload); ok {
|
||||
return payloadEditor.EditMessageWithPayload(
|
||||
ctx,
|
||||
chatID,
|
||||
entry.id,
|
||||
outboundMessageEditPayload(msg, content),
|
||||
)
|
||||
}
|
||||
return editor.EditMessage(ctx, chatID, entry.id, content)
|
||||
}()
|
||||
if err == nil {
|
||||
trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, &msg.Context)
|
||||
if tracker, ok := ch.(toolFeedbackMessageTracker); ok && isToolFeedback {
|
||||
tracker.RecordToolFeedbackMessage(trackedChatID, entry.id, trackedContent)
|
||||
@@ -643,6 +676,18 @@ func reasoningStreamerFrom(streamer bus.Streamer) bus.ReasoningStreamer {
|
||||
return nil
|
||||
}
|
||||
|
||||
type modelNameStreamer interface {
|
||||
SetModelName(modelName string)
|
||||
}
|
||||
|
||||
func setStreamerModelName(streamer any, modelName string) {
|
||||
setter, ok := streamer.(modelNameStreamer)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
setter.SetModelName(modelName)
|
||||
}
|
||||
|
||||
// splitMarkerStreamer turns accumulated streaming text containing
|
||||
// MessageSplitMarker into separate channel stream messages.
|
||||
type splitMarkerStreamer struct {
|
||||
@@ -654,6 +699,7 @@ type splitMarkerStreamer struct {
|
||||
finalized bool
|
||||
onFinalize func(context.Context, string)
|
||||
clearMarker func()
|
||||
modelName string
|
||||
}
|
||||
|
||||
func (s *splitMarkerStreamer) Update(ctx context.Context, content string) error {
|
||||
@@ -682,6 +728,7 @@ func (s *splitMarkerStreamer) UpdateReasoning(ctx context.Context, content strin
|
||||
if s.reasoning == nil {
|
||||
return nil
|
||||
}
|
||||
setStreamerModelName(s.reasoning, s.modelName)
|
||||
return s.reasoning.UpdateReasoning(ctx, content)
|
||||
}
|
||||
|
||||
@@ -691,9 +738,18 @@ func (s *splitMarkerStreamer) FinalizeReasoning(ctx context.Context, content str
|
||||
if s.reasoning == nil {
|
||||
return nil
|
||||
}
|
||||
setStreamerModelName(s.reasoning, s.modelName)
|
||||
return s.reasoning.FinalizeReasoning(ctx, content)
|
||||
}
|
||||
|
||||
func (s *splitMarkerStreamer) SetModelName(modelName string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.modelName = strings.TrimSpace(modelName)
|
||||
setStreamerModelName(s.current, s.modelName)
|
||||
setStreamerModelName(s.reasoning, s.modelName)
|
||||
}
|
||||
|
||||
func (s *splitMarkerStreamer) Cancel(ctx context.Context) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -772,6 +828,7 @@ func (s *splitMarkerStreamer) ensureCurrentLocked(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
s.current = streamer
|
||||
setStreamerModelName(s.current, s.modelName)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -856,6 +913,10 @@ func (s *finalizeHookStreamer) FinalizeReasoning(ctx context.Context, content st
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *finalizeHookStreamer) SetModelName(modelName string) {
|
||||
setStreamerModelName(s.Streamer, strings.TrimSpace(modelName))
|
||||
}
|
||||
|
||||
func (s *finalizeHookStreamer) runFinalizeHook(ctx context.Context, content string) {
|
||||
if s.onFinalize != nil {
|
||||
s.onFinalize(ctx, content)
|
||||
|
||||
@@ -142,11 +142,21 @@ func (m *mockReasoningStreamer) FinalizeReasoning(_ context.Context, content str
|
||||
return nil
|
||||
}
|
||||
|
||||
type modelTrackingReasoningStreamer struct {
|
||||
mockReasoningStreamer
|
||||
modelNames []string
|
||||
}
|
||||
|
||||
func (m *modelTrackingReasoningStreamer) SetModelName(modelName string) {
|
||||
m.modelNames = append(m.modelNames, strings.TrimSpace(modelName))
|
||||
}
|
||||
|
||||
type recordingStreamSegment struct {
|
||||
updates []string
|
||||
finals []string
|
||||
finalUsage *bus.ContextUsage
|
||||
canceledCount int
|
||||
modelNames []string
|
||||
}
|
||||
|
||||
func (s *recordingStreamSegment) Update(_ context.Context, content string) error {
|
||||
@@ -168,6 +178,10 @@ func (s *recordingStreamSegment) Cancel(context.Context) {
|
||||
s.canceledCount++
|
||||
}
|
||||
|
||||
func (s *recordingStreamSegment) SetModelName(modelName string) {
|
||||
s.modelNames = append(s.modelNames, strings.TrimSpace(modelName))
|
||||
}
|
||||
|
||||
type mockStreamingChannel struct {
|
||||
mockMessageEditor
|
||||
streamer Streamer
|
||||
@@ -2068,6 +2082,42 @@ func TestGetStreamer_PreservesReasoningStreamer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStreamer_PreservesModelNameSetter(t *testing.T) {
|
||||
m := newTestManager()
|
||||
inner := &modelTrackingReasoningStreamer{}
|
||||
ch := &mockStreamingChannel{
|
||||
streamer: inner,
|
||||
}
|
||||
m.channels["test"] = ch
|
||||
|
||||
streamer, ok := m.GetStreamer(context.Background(), "test", "123", "")
|
||||
if !ok {
|
||||
t.Fatal("expected streamer to be available")
|
||||
}
|
||||
setter, ok := streamer.(interface{ SetModelName(modelName string) })
|
||||
if !ok {
|
||||
t.Fatal("manager-wrapped streamer should preserve SetModelName")
|
||||
}
|
||||
setter.SetModelName("gpt-5.4")
|
||||
if err := streamer.Update(context.Background(), "hello"); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
reasoningStreamer, ok := streamer.(bus.ReasoningStreamer)
|
||||
if !ok {
|
||||
t.Fatal("manager-wrapped streamer should preserve ReasoningStreamer")
|
||||
}
|
||||
setter.SetModelName("gpt-5.4")
|
||||
if err := reasoningStreamer.UpdateReasoning(context.Background(), "thinking"); err != nil {
|
||||
t.Fatalf("UpdateReasoning() error = %v", err)
|
||||
}
|
||||
if len(inner.modelNames) != 2 {
|
||||
t.Fatalf("model name calls = %v, want 2 forwarded calls", inner.modelNames)
|
||||
}
|
||||
if inner.modelNames[0] != "gpt-5.4" || inner.modelNames[1] != "gpt-5.4" {
|
||||
t.Fatalf("model name calls = %v, want both forwarded as gpt-5.4", inner.modelNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStreamer_SplitOnMarkerStreamsSeparateSegments(t *testing.T) {
|
||||
m := newTestManager()
|
||||
m.config = &config.Config{
|
||||
@@ -2188,6 +2238,58 @@ func TestGetStreamer_SplitOnMarkerKeepsReasoningOnInitialStreamer(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStreamer_SplitOnMarkerPreservesModelNameSetter(t *testing.T) {
|
||||
m := newTestManager()
|
||||
m.config = &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
SplitOnMarker: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
initial := &modelTrackingReasoningStreamer{}
|
||||
next := &recordingStreamSegment{}
|
||||
callCount := 0
|
||||
ch := &mockStreamingChannel{
|
||||
beginStreamFn: func(context.Context, string) (Streamer, error) {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
return initial, nil
|
||||
}
|
||||
return next, nil
|
||||
},
|
||||
}
|
||||
m.channels["test"] = ch
|
||||
|
||||
streamer, ok := m.GetStreamer(context.Background(), "test", "123", "")
|
||||
if !ok {
|
||||
t.Fatal("expected streamer to be available")
|
||||
}
|
||||
setter, ok := streamer.(interface{ SetModelName(modelName string) })
|
||||
if !ok {
|
||||
t.Fatal("split streamer should preserve SetModelName")
|
||||
}
|
||||
setter.SetModelName("gpt-5.4-mini")
|
||||
if err := streamer.Update(context.Background(), "hello<|[SPLIT]|>world"); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
reasoningStreamer, ok := streamer.(bus.ReasoningStreamer)
|
||||
if !ok {
|
||||
t.Fatal("split streamer should preserve ReasoningStreamer")
|
||||
}
|
||||
if err := reasoningStreamer.UpdateReasoning(context.Background(), "thinking"); err != nil {
|
||||
t.Fatalf("UpdateReasoning() error = %v", err)
|
||||
}
|
||||
|
||||
if len(initial.modelNames) == 0 || initial.modelNames[0] != "gpt-5.4-mini" {
|
||||
t.Fatalf("initial model names = %v, want forwarded gpt-5.4-mini", initial.modelNames)
|
||||
}
|
||||
if len(next.modelNames) == 0 || next.modelNames[0] != "gpt-5.4-mini" {
|
||||
t.Fatalf("next model names = %v, want forwarded gpt-5.4-mini", next.modelNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStreamer_FinalizeSeparateMessagesClearsTrackedToolFeedback(t *testing.T) {
|
||||
m := newTestManager()
|
||||
m.config = &config.Config{
|
||||
|
||||
@@ -325,6 +325,9 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
||||
PayloadKeyContent: content,
|
||||
"message_id": msgID,
|
||||
}
|
||||
if modelName := strings.TrimSpace(msg.Context.Raw[PayloadKeyModelName]); modelName != "" {
|
||||
payload[PayloadKeyModelName] = modelName
|
||||
}
|
||||
switch {
|
||||
case isThought:
|
||||
payload[PayloadKeyKind] = MessageKindThought
|
||||
@@ -359,6 +362,15 @@ func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID
|
||||
return c.editMessage(ctx, chatID, messageID, content, nil)
|
||||
}
|
||||
|
||||
func (c *PicoChannel) EditMessageWithPayload(
|
||||
ctx context.Context,
|
||||
chatID string,
|
||||
messageID string,
|
||||
payload map[string]any,
|
||||
) error {
|
||||
return c.editMessagePayload(ctx, chatID, messageID, payload, nil)
|
||||
}
|
||||
|
||||
// DeleteMessage implements channels.MessageDeleter.
|
||||
func (c *PicoChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error {
|
||||
outMsg := newMessage(TypeMessageDelete, map[string]any{
|
||||
@@ -419,14 +431,23 @@ func (c *PicoChannel) finalizeTrackedToolFeedbackMessage(
|
||||
ctx context.Context,
|
||||
chatID string,
|
||||
content string,
|
||||
editFn func(context.Context, string, string, string, *bus.ContextUsage) error,
|
||||
editFn func(context.Context, string, string, map[string]any, *bus.ContextUsage) error,
|
||||
payload map[string]any,
|
||||
contextUsage *bus.ContextUsage,
|
||||
) ([]string, bool) {
|
||||
msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID)
|
||||
if !ok || editFn == nil {
|
||||
return nil, false
|
||||
}
|
||||
if err := editFn(ctx, chatID, msgID, content, contextUsage); err != nil {
|
||||
if payload == nil {
|
||||
payload = map[string]any{
|
||||
PayloadKeyContent: content,
|
||||
}
|
||||
}
|
||||
if _, ok := payload[PayloadKeyContent]; !ok {
|
||||
payload[PayloadKeyContent] = content
|
||||
}
|
||||
if err := editFn(ctx, chatID, msgID, payload, contextUsage); err != nil {
|
||||
c.RecordToolFeedbackMessage(chatID, msgID, baseContent)
|
||||
return nil, false
|
||||
}
|
||||
@@ -437,7 +458,20 @@ func (c *PicoChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.O
|
||||
if !outboundMessageFinalizesTrackedToolFeedback(msg) {
|
||||
return nil, false
|
||||
}
|
||||
return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.editMessage, msg.ContextUsage)
|
||||
payload := map[string]any{
|
||||
PayloadKeyContent: msg.Content,
|
||||
}
|
||||
if modelName := strings.TrimSpace(msg.Context.Raw[PayloadKeyModelName]); modelName != "" {
|
||||
payload[PayloadKeyModelName] = modelName
|
||||
}
|
||||
return c.finalizeTrackedToolFeedbackMessage(
|
||||
ctx,
|
||||
msg.ChatID,
|
||||
msg.Content,
|
||||
c.editMessagePayload,
|
||||
payload,
|
||||
msg.ContextUsage,
|
||||
)
|
||||
}
|
||||
|
||||
// StartTyping implements channels.TypingCapable.
|
||||
@@ -496,6 +530,7 @@ func (c *PicoChannel) BeginStream(ctx context.Context, chatID string) (channels.
|
||||
type picoStreamer struct {
|
||||
channel *PicoChannel
|
||||
chatID string
|
||||
modelName string
|
||||
messageID string
|
||||
reasoningID string
|
||||
throttleInterval time.Duration
|
||||
@@ -509,6 +544,15 @@ type picoStreamer struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *picoStreamer) SetModelName(modelName string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.modelName = strings.TrimSpace(modelName)
|
||||
}
|
||||
|
||||
func (s *picoStreamer) Update(ctx context.Context, content string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -613,13 +657,23 @@ func (s *picoStreamer) sendLocked(ctx context.Context, content string, contextUs
|
||||
PayloadKeyContent: content,
|
||||
"message_id": s.messageID,
|
||||
}
|
||||
if s.modelName != "" {
|
||||
payload[PayloadKeyModelName] = s.modelName
|
||||
}
|
||||
setContextUsagePayload(payload, contextUsage)
|
||||
outMsg := newMessage(TypeMessageCreate, payload)
|
||||
if err := s.channel.broadcastToSession(s.chatID, outMsg); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if content != s.lastContent || contextUsage != nil {
|
||||
if err := s.channel.editMessage(ctx, s.chatID, s.messageID, content, contextUsage); err != nil {
|
||||
payload := map[string]any{
|
||||
PayloadKeyContent: content,
|
||||
"message_id": s.messageID,
|
||||
}
|
||||
if s.modelName != "" {
|
||||
payload[PayloadKeyModelName] = s.modelName
|
||||
}
|
||||
if err := s.channel.editMessagePayload(ctx, s.chatID, s.messageID, payload, contextUsage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -642,6 +696,9 @@ func (s *picoStreamer) sendReasoningLocked(ctx context.Context, content string)
|
||||
PayloadKeyKind: MessageKindThought,
|
||||
PayloadKeyThought: true,
|
||||
}
|
||||
if s.modelName != "" {
|
||||
payload[PayloadKeyModelName] = s.modelName
|
||||
}
|
||||
outMsg := newMessage(TypeMessageCreate, payload)
|
||||
if err := s.channel.broadcastToSession(s.chatID, outMsg); err != nil {
|
||||
return err
|
||||
@@ -653,6 +710,9 @@ func (s *picoStreamer) sendReasoningLocked(ctx context.Context, content string)
|
||||
PayloadKeyKind: MessageKindThought,
|
||||
PayloadKeyThought: true,
|
||||
}
|
||||
if s.modelName != "" {
|
||||
payload[PayloadKeyModelName] = s.modelName
|
||||
}
|
||||
outMsg := newMessage(TypeMessageUpdate, payload)
|
||||
if err := s.channel.broadcastToSession(s.chatID, outMsg); err != nil {
|
||||
return err
|
||||
@@ -744,6 +804,9 @@ func (c *PicoChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag
|
||||
"attachments": attachments,
|
||||
"message_id": msgID,
|
||||
})
|
||||
if modelName := strings.TrimSpace(msg.Context.Raw[PayloadKeyModelName]); modelName != "" {
|
||||
outMsg.Payload[PayloadKeyModelName] = modelName
|
||||
}
|
||||
|
||||
if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil {
|
||||
return nil, err
|
||||
@@ -1358,11 +1421,30 @@ func (c *PicoChannel) editMessage(
|
||||
content string,
|
||||
contextUsage *bus.ContextUsage,
|
||||
) error {
|
||||
payload := map[string]any{
|
||||
"message_id": messageID,
|
||||
"content": content,
|
||||
return c.editMessagePayload(ctx, chatID, messageID, map[string]any{
|
||||
PayloadKeyContent: content,
|
||||
}, contextUsage)
|
||||
}
|
||||
|
||||
func (c *PicoChannel) editMessagePayload(
|
||||
ctx context.Context,
|
||||
chatID string,
|
||||
messageID string,
|
||||
payload map[string]any,
|
||||
contextUsage *bus.ContextUsage,
|
||||
) error {
|
||||
if payload == nil {
|
||||
payload = map[string]any{}
|
||||
}
|
||||
setContextUsagePayload(payload, contextUsage)
|
||||
outMsg := newMessage(TypeMessageUpdate, payload)
|
||||
normalized := make(map[string]any, len(payload)+1)
|
||||
for key, value := range payload {
|
||||
normalized[key] = value
|
||||
}
|
||||
if _, ok := normalized[PayloadKeyContent]; !ok {
|
||||
normalized[PayloadKeyContent] = ""
|
||||
}
|
||||
normalized["message_id"] = messageID
|
||||
setContextUsagePayload(normalized, contextUsage)
|
||||
outMsg := newMessage(TypeMessageUpdate, normalized)
|
||||
return c.broadcastToSession(chatID, outMsg)
|
||||
}
|
||||
|
||||
@@ -46,12 +46,15 @@ func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T
|
||||
context.Background(),
|
||||
"pico:chat-1",
|
||||
"final reply",
|
||||
func(_ context.Context, chatID, messageID, content string, contextUsage *bus.ContextUsage) error {
|
||||
func(_ context.Context, chatID, messageID string, payload map[string]any, contextUsage *bus.ContextUsage) error {
|
||||
if _, ok := ch.currentToolFeedbackMessage(chatID); ok {
|
||||
t.Fatal("expected tracked tool feedback to be stopped before edit")
|
||||
}
|
||||
if chatID != "pico:chat-1" || messageID != "msg-1" || content != "final reply" {
|
||||
t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content)
|
||||
if chatID != "pico:chat-1" || messageID != "msg-1" {
|
||||
t.Fatalf("unexpected edit args: %s %s", chatID, messageID)
|
||||
}
|
||||
if got := payload[PayloadKeyContent]; got != "final reply" {
|
||||
t.Fatalf("unexpected content payload: %#v", got)
|
||||
}
|
||||
if contextUsage != nil {
|
||||
t.Fatalf("unexpected context usage: %+v", contextUsage)
|
||||
@@ -59,6 +62,7 @@ func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if !handled {
|
||||
t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message")
|
||||
@@ -115,7 +119,8 @@ func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) {
|
||||
Channel: "pico",
|
||||
ChatID: "pico:sess-1",
|
||||
Raw: map[string]string{
|
||||
"message_kind": MessageKindThought,
|
||||
"message_kind": MessageKindThought,
|
||||
PayloadKeyModelName: "gpt-5.4-mini",
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
@@ -134,6 +139,9 @@ func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) {
|
||||
if got := payload[PayloadKeyKind]; got != MessageKindThought {
|
||||
t.Fatalf("thought kind = %#v, want %q", got, MessageKindThought)
|
||||
}
|
||||
if got := payload[PayloadKeyModelName]; got != "gpt-5.4-mini" {
|
||||
t.Fatalf("thought model_name = %#v, want %q", got, "gpt-5.4-mini")
|
||||
}
|
||||
if got := payload["message_id"]; got == "msg-progress" || got == nil || got == "" {
|
||||
t.Fatalf("thought message_id = %#v, want new non-progress id", got)
|
||||
}
|
||||
@@ -151,6 +159,9 @@ func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) {
|
||||
Context: bus.InboundContext{
|
||||
Channel: "pico",
|
||||
ChatID: "pico:sess-1",
|
||||
Raw: map[string]string{
|
||||
PayloadKeyModelName: "gpt-5.4",
|
||||
},
|
||||
},
|
||||
ContextUsage: &bus.ContextUsage{
|
||||
UsedTokens: 321,
|
||||
@@ -174,6 +185,9 @@ func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) {
|
||||
if got := payload[PayloadKeyContent]; got != "final reply" {
|
||||
t.Fatalf("final content = %#v, want %q", got, "final reply")
|
||||
}
|
||||
if got := payload[PayloadKeyModelName]; got != "gpt-5.4" {
|
||||
t.Fatalf("final model_name = %#v, want %q", got, "gpt-5.4")
|
||||
}
|
||||
rawUsage, ok := payload["context_usage"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("final context_usage = %#v, want map payload", payload["context_usage"])
|
||||
@@ -193,6 +207,54 @@ func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSend_ToolCallsMessageIncludesModelName(t *testing.T) {
|
||||
ch := newTestPicoChannel(t)
|
||||
|
||||
if err := ch.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
defer ch.Stop(context.Background())
|
||||
|
||||
clientConn, received, cleanup := newTestPicoWebSocket(t)
|
||||
defer cleanup()
|
||||
ch.addConnForTest(&picoConn{id: "conn-1", conn: clientConn, sessionID: "sess-1"})
|
||||
|
||||
if _, err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||
ChatID: "pico:sess-1",
|
||||
Content: "",
|
||||
Context: bus.InboundContext{
|
||||
Channel: "pico",
|
||||
ChatID: "pico:sess-1",
|
||||
Raw: map[string]string{
|
||||
"message_kind": MessageKindToolCalls,
|
||||
PayloadKeyModelName: "gpt-5.4",
|
||||
PayloadKeyToolCalls: `[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"README.md\"}"}}]`,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("Send(tool_calls) error = %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case msg := <-received:
|
||||
if msg.Type != TypeMessageCreate {
|
||||
t.Fatalf("tool_calls message type = %q, want %q", msg.Type, TypeMessageCreate)
|
||||
}
|
||||
payload := msg.Payload
|
||||
if got := payload[PayloadKeyKind]; got != MessageKindToolCalls {
|
||||
t.Fatalf("tool_calls kind = %#v, want %q", got, MessageKindToolCalls)
|
||||
}
|
||||
if got := payload[PayloadKeyModelName]; got != "gpt-5.4" {
|
||||
t.Fatalf("tool_calls model_name = %#v, want %q", got, "gpt-5.4")
|
||||
}
|
||||
if _, ok := payload[PayloadKeyToolCalls].([]any); !ok {
|
||||
t.Fatalf("tool_calls payload = %#v, want parsed array", payload[PayloadKeyToolCalls])
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected tool_calls message to be delivered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPlaceholder_EmitsNormalMessageWithoutKind(t *testing.T) {
|
||||
ch := newTestPicoChannel(t)
|
||||
ch.bc.Placeholder.Enabled = true
|
||||
@@ -257,6 +319,9 @@ func TestBeginStream_CreatesAndUpdatesSameMessage(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("BeginStream() error = %v", err)
|
||||
}
|
||||
if setter, ok := streamer.(interface{ SetModelName(modelName string) }); ok {
|
||||
setter.SetModelName("gpt-5.4")
|
||||
}
|
||||
if err := streamer.Update(context.Background(), "hello"); err != nil {
|
||||
t.Fatalf("Update(first) error = %v", err)
|
||||
}
|
||||
@@ -271,6 +336,9 @@ func TestBeginStream_CreatesAndUpdatesSameMessage(t *testing.T) {
|
||||
if got := first.Payload[PayloadKeyContent]; got != "hello" {
|
||||
t.Fatalf("first content = %#v, want hello", got)
|
||||
}
|
||||
if got := first.Payload[PayloadKeyModelName]; got != "gpt-5.4" {
|
||||
t.Fatalf("first model_name = %#v, want %q", got, "gpt-5.4")
|
||||
}
|
||||
|
||||
rawStreamer := streamer.(*picoStreamer)
|
||||
rawStreamer.mu.Lock()
|
||||
@@ -290,6 +358,9 @@ func TestBeginStream_CreatesAndUpdatesSameMessage(t *testing.T) {
|
||||
if got := second.Payload[PayloadKeyContent]; got != secondContent {
|
||||
t.Fatalf("second content = %#v, want %q", got, secondContent)
|
||||
}
|
||||
if got := second.Payload[PayloadKeyModelName]; got != "gpt-5.4" {
|
||||
t.Fatalf("second model_name = %#v, want %q", got, "gpt-5.4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeginStream_DefaultStreamingShowsSmallIncrements(t *testing.T) {
|
||||
@@ -355,6 +426,9 @@ func TestBeginStream_StreamsReasoningAsThoughtUpdates(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("pico stream should support reasoning updates")
|
||||
}
|
||||
if setter, ok := streamer.(interface{ SetModelName(modelName string) }); ok {
|
||||
setter.SetModelName("gpt-5.4-mini")
|
||||
}
|
||||
if err := reasoningStreamer.UpdateReasoning(context.Background(), "thinking"); err != nil {
|
||||
t.Fatalf("UpdateReasoning(first) error = %v", err)
|
||||
}
|
||||
@@ -372,6 +446,9 @@ func TestBeginStream_StreamsReasoningAsThoughtUpdates(t *testing.T) {
|
||||
if got := first.Payload[PayloadKeyContent]; got != "thinking" {
|
||||
t.Fatalf("first content = %#v, want thinking", got)
|
||||
}
|
||||
if got := first.Payload[PayloadKeyModelName]; got != "gpt-5.4-mini" {
|
||||
t.Fatalf("first model_name = %#v, want %q", got, "gpt-5.4-mini")
|
||||
}
|
||||
|
||||
if err := reasoningStreamer.UpdateReasoning(context.Background(), "thinking more"); err != nil {
|
||||
t.Fatalf("UpdateReasoning(second) error = %v", err)
|
||||
@@ -389,6 +466,9 @@ func TestBeginStream_StreamsReasoningAsThoughtUpdates(t *testing.T) {
|
||||
if got := second.Payload[PayloadKeyContent]; got != "thinking more" {
|
||||
t.Fatalf("second content = %#v, want thinking more", got)
|
||||
}
|
||||
if got := second.Payload[PayloadKeyModelName]; got != "gpt-5.4-mini" {
|
||||
t.Fatalf("second model_name = %#v, want %q", got, "gpt-5.4-mini")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeginStream_ThrottlesIntermediateUpdatesAndFinalFlushes(t *testing.T) {
|
||||
@@ -473,6 +553,9 @@ func TestBeginStream_FinalizeIncludesContextUsage(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("BeginStream() error = %v", err)
|
||||
}
|
||||
if setter, ok := streamer.(interface{ SetModelName(modelName string) }); ok {
|
||||
setter.SetModelName("gpt-5.4")
|
||||
}
|
||||
if err := streamer.Update(context.Background(), "partial"); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
@@ -501,6 +584,9 @@ func TestBeginStream_FinalizeIncludesContextUsage(t *testing.T) {
|
||||
if got := final.Payload["message_id"]; got != msgID {
|
||||
t.Fatalf("final message_id = %#v, want %q", got, msgID)
|
||||
}
|
||||
if got := final.Payload[PayloadKeyModelName]; got != "gpt-5.4" {
|
||||
t.Fatalf("final model_name = %#v, want %q", got, "gpt-5.4")
|
||||
}
|
||||
rawUsage, ok := final.Payload["context_usage"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("final context_usage = %#v, want map", final.Payload["context_usage"])
|
||||
|
||||
@@ -27,6 +27,7 @@ const (
|
||||
PayloadKeyKind = "kind"
|
||||
PayloadKeyPlaceholder = "placeholder"
|
||||
PayloadKeyToolCalls = "tool_calls"
|
||||
PayloadKeyModelName = "model_name"
|
||||
|
||||
MessageKindThought = "thought"
|
||||
MessageKindToolCalls = "tool_calls"
|
||||
|
||||
Reference in New Issue
Block a user