%s", escaped))
@@ -57,6 +64,29 @@ func markdownToTelegramHTML(text string) string {
return text
}
+type linkMatch struct {
+ text string
+ links [][2]string // [label, url]
+}
+
+func extractLinks(text string) linkMatch {
+ matches := reLink.FindAllStringSubmatch(text, -1)
+
+ extracted := make([][2]string, 0, len(matches))
+ for _, match := range matches {
+ extracted = append(extracted, [2]string{match[1], match[2]})
+ }
+
+ i := 0
+ text = reLink.ReplaceAllStringFunc(text, func(m string) string {
+ placeholder := fmt.Sprintf("\x00LK%d\x00", i)
+ i++
+ return placeholder
+ })
+
+ return linkMatch{text: text, links: extracted}
+}
+
type codeBlockMatch struct {
text string
codes []string
diff --git a/pkg/channels/telegram/parser_markdown_to_html_test.go b/pkg/channels/telegram/parser_markdown_to_html_test.go
new file mode 100644
index 000000000..7754ee076
--- /dev/null
+++ b/pkg/channels/telegram/parser_markdown_to_html_test.go
@@ -0,0 +1,66 @@
+package telegram
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func Test_markdownToTelegramHTML(t *testing.T) {
+ cases := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "plain text",
+ input: "hello world",
+ expected: "hello world",
+ },
+ {
+ name: "bold",
+ input: "**bold text**",
+ expected: "bold text",
+ },
+ {
+ name: "italic",
+ input: "_italic text_",
+ expected: "italic text",
+ },
+ {
+ name: "link without underscores in URL",
+ input: "[click here](https://example.com/path)",
+ expected: `click here`,
+ },
+ {
+ name: "link with underscores in URL is not corrupted by italic regex",
+ // Google Flights URLs use URL-safe base64 with underscores in the tfs param.
+ // Previously reItalic ran after reLink, matching _text_ inside href and injecting
+ // tags into the URL, which broke the link in Telegram.
+ input: "[3 → 10 сентября — от $202](https://www.google.com/travel/flights/search?tfs=CBwQAho_EgoyURL_safe_base64)",
+ expected: `3 → 10 сентября — от $202`,
+ },
+ {
+ name: "multiple links all survive",
+ input: "[first](https://a.com/path_one) and [second](https://b.com/path_two_x)",
+ expected: `first and second`,
+ },
+ {
+ name: "link label with HTML special chars is escaped",
+ input: "[a & b](https://example.com)",
+ expected: `a & b`,
+ },
+ {
+ name: "HTML special chars in plain text are escaped",
+ input: "a & b < c > d",
+ expected: "a & b < c > d",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ actual := markdownToTelegramHTML(tc.input)
+ require.Equal(t, tc.expected, actual)
+ })
+ }
+}
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index f64a8f79b..831eb43cc 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -168,26 +168,27 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
return nil
}
-func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil {
- return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
+ return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
if msg.Content == "" {
- return nil
+ return nil, nil
}
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
// so msg.Content is guaranteed to be within that limit. We still need to
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
replyToID := msg.ReplyToMessageID
+ var messageIDs []string
queue := []string{msg.Content}
for len(queue) > 0 {
chunk := queue[0]
@@ -206,16 +207,18 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
}
if smallerLen <= 0 {
- if err := c.sendChunk(ctx, sendChunkParams{
+ msgID, err := c.sendChunk(ctx, sendChunkParams{
chatID: chatID,
threadID: threadID,
content: content,
replyToID: replyToID,
mdFallback: chunk,
useMarkdownV2: useMarkdownV2,
- }); err != nil {
- return err
+ })
+ if err != nil {
+ return nil, err
}
+ messageIDs = append(messageIDs, msgID)
replyToID = ""
continue
}
@@ -244,21 +247,23 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
continue
}
- if err := c.sendChunk(ctx, sendChunkParams{
+ msgID, err := c.sendChunk(ctx, sendChunkParams{
chatID: chatID,
threadID: threadID,
content: content,
replyToID: replyToID,
mdFallback: chunk,
useMarkdownV2: useMarkdownV2,
- }); err != nil {
- return err
+ })
+ if err != nil {
+ return nil, err
}
+ messageIDs = append(messageIDs, msgID)
// Only the first chunk should be a reply; subsequent chunks are normal messages.
replyToID = ""
}
- return nil
+ return messageIDs, nil
}
type sendChunkParams struct {
@@ -275,7 +280,7 @@ type sendChunkParams struct {
func (c *TelegramChannel) sendChunk(
ctx context.Context,
params sendChunkParams,
-) error {
+) (string, error) {
tgMsg := tu.Message(tu.ID(params.chatID), params.content)
tgMsg.MessageThreadID = params.threadID
if params.useMarkdownV2 {
@@ -292,17 +297,19 @@ func (c *TelegramChannel) sendChunk(
}
}
- if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
+ pMsg, err := c.bot.SendMessage(ctx, tgMsg)
+ if err != nil {
logParseFailed(err, params.useMarkdownV2)
tgMsg.Text = params.mdFallback
tgMsg.ParseMode = ""
- if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
- return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
+ pMsg, err = c.bot.SendMessage(ctx, tgMsg)
+ if err != nil {
+ return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary)
}
}
- return nil
+ return strconv.Itoa(pMsg.MessageID), nil
}
// maxTypingDuration limits how long the typing indicator can run.
@@ -420,21 +427,22 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
}
// SendMedia implements the channels.MediaSender interface.
-func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil {
- return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
+ return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
store := c.GetMediaStore()
if store == nil {
- return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
+ return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
+ var messageIDs []string
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
@@ -454,6 +462,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
continue
}
+ var tgResult *telego.Message
switch part.Type {
case "image":
params := &telego.SendPhotoParams{
@@ -462,11 +471,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Photo: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendPhoto(ctx, params)
+ tgResult, err = c.bot.SendPhoto(ctx, params)
if err != nil && strings.Contains(err.Error(), "PHOTO_INVALID_DIMENSIONS") {
if _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil {
file.Close()
- return fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary)
}
docParams := &telego.SendDocumentParams{
@@ -475,7 +484,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Document: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendDocument(ctx, docParams)
+ tgResult, err = c.bot.SendDocument(ctx, docParams)
}
case "audio":
// Send OGG files with "voice" in the filename as Telegram voice
@@ -488,7 +497,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Voice: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendVoice(ctx, vparams)
+ tgResult, err = c.bot.SendVoice(ctx, vparams)
} else {
params := &telego.SendAudioParams{
ChatID: tu.ID(chatID),
@@ -496,7 +505,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Audio: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendAudio(ctx, params)
+ tgResult, err = c.bot.SendAudio(ctx, params)
}
case "video":
params := &telego.SendVideoParams{
@@ -505,7 +514,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Video: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendVideo(ctx, params)
+ tgResult, err = c.bot.SendVideo(ctx, params)
default: // "file" or unknown types
params := &telego.SendDocumentParams{
ChatID: tu.ID(chatID),
@@ -513,9 +522,12 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Document: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendDocument(ctx, params)
+ tgResult, err = c.bot.SendDocument(ctx, params)
}
+ if tgResult != nil {
+ messageIDs = append(messageIDs, strconv.Itoa(tgResult.MessageID))
+ }
file.Close()
if err != nil {
@@ -523,11 +535,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
"type": part.Type,
"error": err.Error(),
})
- return fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
}
}
- return nil
+ return messageIDs, nil
}
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
@@ -660,6 +672,23 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
content = cleaned
}
+ if message.ReplyToMessage != nil {
+ quotedMedia := quotedTelegramMediaRefs(
+ message.ReplyToMessage,
+ func(fileID, ext, filename string) string {
+ localPath := c.downloadFile(ctx, fileID, ext)
+ if localPath == "" {
+ return ""
+ }
+ return storeMedia(localPath, filename)
+ },
+ )
+ if len(quotedMedia) > 0 {
+ mediaPaths = append(quotedMedia, mediaPaths...)
+ }
+ content = c.prependTelegramQuotedReply(content, message.ReplyToMessage)
+ }
+
// For forum topics, embed the thread ID as "chatID/threadID" so replies
// route to the correct topic and each topic gets its own session.
// Only forum groups (IsForum) are handled; regular group reply threads
@@ -693,6 +722,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
"first_name": user.FirstName,
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
}
+ if message.ReplyToMessage != nil {
+ metadata["reply_to_message_id"] = fmt.Sprintf("%d", message.ReplyToMessage.MessageID)
+ }
// Set parent_peer metadata for per-topic agent binding.
if message.Chat.IsForum && threadID != 0 {
@@ -713,6 +745,122 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return nil
}
+func (c *TelegramChannel) prependTelegramQuotedReply(content string, reply *telego.Message) string {
+ quoted := strings.TrimSpace(telegramQuotedContent(reply))
+ if quoted == "" {
+ return content
+ }
+
+ author := telegramQuotedAuthor(reply)
+ role := c.telegramQuotedRole(reply)
+ if strings.TrimSpace(content) == "" {
+ return fmt.Sprintf("[quoted %s message from %s]: %s", role, author, quoted)
+ }
+ return fmt.Sprintf("[quoted %s message from %s]: %s\n\n%s", role, author, quoted, content)
+}
+
+func (c *TelegramChannel) telegramQuotedRole(message *telego.Message) string {
+ if message == nil {
+ return "unknown"
+ }
+
+ if message.From != nil {
+ if !message.From.IsBot {
+ return "user"
+ }
+ if c.isOwnBotUser(message.From) {
+ return "assistant"
+ }
+ return "bot"
+ }
+
+ if message.SenderChat != nil {
+ return "chat"
+ }
+
+ return "unknown"
+}
+
+func (c *TelegramChannel) isOwnBotUser(user *telego.User) bool {
+ if c == nil || c.bot == nil || user == nil || !user.IsBot {
+ return false
+ }
+
+ if botID := c.bot.ID(); botID != 0 && user.ID == botID {
+ return true
+ }
+
+ botUsername := strings.TrimPrefix(strings.TrimSpace(c.bot.Username()), "@")
+ if botUsername == "" {
+ return false
+ }
+ return strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(user.Username), "@"), botUsername)
+}
+
+func telegramQuotedAuthor(message *telego.Message) string {
+ if message == nil || message.From == nil {
+ return "unknown"
+ }
+ if username := strings.TrimSpace(message.From.Username); username != "" {
+ return username
+ }
+ if firstName := strings.TrimSpace(message.From.FirstName); firstName != "" {
+ return firstName
+ }
+ return "unknown"
+}
+
+func telegramQuotedContent(message *telego.Message) string {
+ if message == nil {
+ return ""
+ }
+
+ var parts []string
+ if text := strings.TrimSpace(message.Text); text != "" {
+ parts = append(parts, text)
+ }
+ if caption := strings.TrimSpace(message.Caption); caption != "" {
+ parts = append(parts, caption)
+ }
+ switch {
+ case len(message.Photo) > 0:
+ parts = append(parts, "[image: photo]")
+ }
+ switch {
+ case message.Voice != nil:
+ parts = append(parts, "[voice]")
+ case message.Audio != nil:
+ parts = append(parts, "[audio]")
+ }
+ if message.Document != nil {
+ parts = append(parts, "[file]")
+ }
+
+ return strings.Join(parts, "\n")
+}
+
+func quotedTelegramMediaRefs(
+ message *telego.Message,
+ resolve func(fileID, ext, filename string) string,
+) []string {
+ if message == nil || resolve == nil {
+ return nil
+ }
+
+ var refs []string
+ if message.Voice != nil {
+ if ref := resolve(message.Voice.FileID, ".ogg", "voice.ogg"); ref != "" {
+ refs = append(refs, ref)
+ }
+ }
+ if message.Audio != nil {
+ if ref := resolve(message.Audio.FileID, ".mp3", "audio.mp3"); ref != "" {
+ refs = append(refs, ref)
+ }
+ }
+ return refs
+}
+
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
if err != nil {
diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go
index fd189d9a7..4f7a2600b 100644
--- a/pkg/channels/telegram/telegram_test.go
+++ b/pkg/channels/telegram/telegram_test.go
@@ -7,6 +7,7 @@ import (
"io"
"os"
"path/filepath"
+ "strconv"
"strings"
"testing"
@@ -104,6 +105,13 @@ func successResponse(t *testing.T) *ta.Response {
return &ta.Response{Ok: true, Result: b}
}
+func successUserResponse(t *testing.T, user *telego.User) *ta.Response {
+ t.Helper()
+ b, err := json.Marshal(user)
+ require.NoError(t, err)
+ return &ta.Response{Ok: true, Result: b}
+}
+
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
return newTestChannelWithConstructor(t, caller, &stubConstructor{})
@@ -168,7 +176,7 @@ func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) {
)
require.NoError(t, err)
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345",
Parts: []bus.MediaPart{{
Type: "image",
@@ -206,7 +214,7 @@ func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {
ref, err := store.Store(localPath, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1")
require.NoError(t, err)
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345",
Parts: []bus.MediaPart{{
Type: "image",
@@ -231,7 +239,7 @@ func TestSend_EmptyContent(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "",
})
@@ -248,7 +256,7 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello, world!",
})
@@ -271,7 +279,7 @@ func TestSend_LongMessage_SingleCall(t *testing.T) {
longContent := strings.Repeat("a", 4000)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: longContent,
})
@@ -294,7 +302,7 @@ func TestSend_HTMLFallback_PerChunk(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello **world**",
})
@@ -312,7 +320,7 @@ func TestSend_HTMLFallback_BothFail(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello",
})
@@ -334,7 +342,7 @@ func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) {
longContent := strings.Repeat("x", 4001)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: longContent,
})
@@ -364,7 +372,7 @@ func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) {
"HTML expansion must exceed Telegram limit for this test to be meaningful",
)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: markdownContent,
})
@@ -399,7 +407,7 @@ func TestSend_HTMLOverflow_WordBoundary(t *testing.T) {
// Ensure the test content matches the intended boundary conditions.
assert.LessOrEqual(t, len([]rune(content)), 4000, "markdown content must not exceed chunk size for this test")
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "123456",
Content: content,
})
@@ -435,7 +443,7 @@ func TestSend_NotRunning(t *testing.T) {
ch := newTestChannel(t, caller)
ch.SetRunning(false)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello",
})
@@ -453,7 +461,7 @@ func TestSend_InvalidChatID(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "not-a-number",
Content: "Hello",
})
@@ -510,7 +518,7 @@ func TestSend_WithForumThreadID(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "-1001234567890/42",
Content: "Hello from topic",
})
@@ -642,6 +650,181 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
assert.Empty(t, inbound.Metadata["parent_peer_id"])
}
+func assertHandleMessageQuotedUserReply(
+ t *testing.T,
+ chatID int64,
+ messageID int,
+ userID int64,
+ userName string,
+ userText string,
+ replyMessageID int,
+ replyText string,
+ replyCaption string,
+ replyAuthorID int64,
+ replyAuthorName string,
+ expectedContent string,
+) {
+ t.Helper()
+
+ messageBus := bus.NewMessageBus()
+ ch := &TelegramChannel{
+ BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
+ chatIDs: make(map[string]int64),
+ ctx: context.Background(),
+ }
+
+ msg := &telego.Message{
+ Text: userText,
+ MessageID: messageID,
+ Chat: telego.Chat{
+ ID: chatID,
+ Type: "private",
+ },
+ From: &telego.User{
+ ID: userID,
+ FirstName: userName,
+ },
+ ReplyToMessage: &telego.Message{
+ MessageID: replyMessageID,
+ Text: replyText,
+ Caption: replyCaption,
+ From: &telego.User{
+ ID: replyAuthorID,
+ FirstName: replyAuthorName,
+ },
+ },
+ }
+
+ err := ch.handleMessage(context.Background(), msg)
+ require.NoError(t, err)
+
+ inbound, ok := <-messageBus.InboundChan()
+ require.True(t, ok)
+ assert.Equal(t, strconv.Itoa(replyMessageID), inbound.Metadata["reply_to_message_id"])
+ assert.Equal(t, expectedContent, inbound.Content)
+}
+
+func TestHandleMessage_ReplyToMessage_PrependsQuotedTextAndMetadata(t *testing.T) {
+ assertHandleMessageQuotedUserReply(
+ t,
+ 456,
+ 21,
+ 11,
+ "Alice",
+ "follow up",
+ 99,
+ "old context",
+ "",
+ 12,
+ "Bob",
+ "[quoted user message from Bob]: old context\n\nfollow up",
+ )
+}
+
+func TestHandleMessage_ReplyToMessage_UsesCaptionWhenQuotedTextMissing(t *testing.T) {
+ assertHandleMessageQuotedUserReply(
+ t,
+ 789,
+ 22,
+ 13,
+ "Carol",
+ "answer this",
+ 100,
+ "",
+ "caption context",
+ 14,
+ "Dave",
+ "[quoted user message from Dave]: caption context\n\nanswer this",
+ )
+}
+
+func TestHandleMessage_ReplyToOwnBotMessage_UsesAssistantRole(t *testing.T) {
+ messageBus := bus.NewMessageBus()
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ if strings.Contains(url, "getMe") {
+ return successUserResponse(t, &telego.User{
+ ID: 42,
+ IsBot: true,
+ FirstName: "Pico",
+ Username: "afjcjsbx_picoclaw_bot",
+ }), nil
+ }
+ t.Fatalf("unexpected API call: %s", url)
+ return nil, nil
+ },
+ }
+ ch := newTestChannel(t, caller)
+ ch.BaseChannel = channels.NewBaseChannel("telegram", nil, messageBus, nil)
+ ch.ctx = context.Background()
+
+ msg := &telego.Message{
+ Text: "ti ricordi questo file?",
+ MessageID: 23,
+ Chat: telego.Chat{
+ ID: 999,
+ Type: "private",
+ },
+ From: &telego.User{
+ ID: 15,
+ FirstName: "Eve",
+ },
+ ReplyToMessage: &telego.Message{
+ MessageID: 101,
+ Text: "Fatto! Ho creato il file notizie_2026_03_28.md",
+ From: &telego.User{
+ ID: 42,
+ IsBot: true,
+ FirstName: "Pico",
+ Username: "afjcjsbx_picoclaw_bot",
+ },
+ },
+ }
+
+ err := ch.handleMessage(context.Background(), msg)
+ require.NoError(t, err)
+
+ inbound, ok := <-messageBus.InboundChan()
+ require.True(t, ok)
+ assert.Equal(t, "101", inbound.Metadata["reply_to_message_id"])
+ assert.Equal(
+ t,
+ "[quoted assistant message from afjcjsbx_picoclaw_bot]: Fatto! Ho creato il file notizie_2026_03_28.md\n\nti ricordi questo file?",
+ inbound.Content,
+ )
+}
+
+func TestTelegramQuotedContent_IncludesVoiceMarkerAlongsideCaption(t *testing.T) {
+ msg := &telego.Message{
+ Caption: "listen to this",
+ Voice: &telego.Voice{
+ FileID: "voice-file",
+ },
+ }
+
+ assert.Equal(t, "listen to this\n[voice]", telegramQuotedContent(msg))
+}
+
+func TestQuotedTelegramMediaRefs_ResolvesQuotedAudioInOrder(t *testing.T) {
+ msg := &telego.Message{
+ Voice: &telego.Voice{FileID: "voice-file"},
+ Audio: &telego.Audio{FileID: "audio-file"},
+ }
+
+ var calls []string
+ refs := quotedTelegramMediaRefs(msg, func(fileID, ext, filename string) string {
+ calls = append(calls, fileID+"|"+ext+"|"+filename)
+ return "ref://" + filename
+ })
+
+ assert.Equal(
+ t,
+ []string{"voice-file|.ogg|voice.ogg", "audio-file|.mp3|audio.mp3"},
+ calls,
+ )
+ assert.Equal(t, []string{"ref://voice.ogg", "ref://audio.mp3"}, refs)
+}
+
func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
diff --git a/pkg/channels/wecom/wecom.go b/pkg/channels/wecom/wecom.go
index 6096b7db3..9689d5171 100644
--- a/pkg/channels/wecom/wecom.go
+++ b/pkg/channels/wecom/wecom.go
@@ -184,20 +184,20 @@ func (c *WeComChannel) BeginStream(_ context.Context, chatID string) (channels.S
}, nil
}
-func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
content := strings.TrimSpace(msg.Content)
if content == "" {
- return nil
+ return nil, nil
}
if turn, ok := c.getTurn(msg.ChatID); ok {
if time.Since(turn.CreatedAt) <= wecomStreamMaxDuration {
if err := c.sendStreamReply(turn, content); err == nil {
c.consumeTurn(msg.ChatID, turn)
- return nil
+ return nil, nil
}
}
c.consumeTurn(msg.ChatID, turn)
@@ -205,20 +205,20 @@ func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
if route, ok := c.routes.Get(msg.ChatID); ok {
if err := c.sendActivePush(route.ChatID, route.ChatType, content); err != nil {
- return err
+ return nil, err
}
- return nil
+ return nil, nil
}
if err := c.sendActivePush(msg.ChatID, 0, content); err != nil {
- return err
+ return nil, err
}
- return nil
+ return nil, nil
}
-func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
route, chatType, hasTurn := c.resolveMediaRoute(msg.ChatID)
@@ -231,7 +231,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
if strings.TrimSpace(part.Ref) == "" {
if caption := strings.TrimSpace(part.Caption); caption != "" {
if err := c.sendActivePush(chatID, chatType, caption); err != nil {
- return err
+ return nil, err
}
}
continue
@@ -239,7 +239,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part)
if err != nil {
- return fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
+ return nil, fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
}
func() {
@@ -283,11 +283,11 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
}
}()
if err != nil {
- return err
+ return nil, err
}
}
- return nil
+ return nil, nil
}
func (c *WeComChannel) connectLoop() {
diff --git a/pkg/channels/wecom/wecom_test.go b/pkg/channels/wecom/wecom_test.go
index c7a4adfc0..b3a87e246 100644
--- a/pkg/channels/wecom/wecom_test.go
+++ b/pkg/channels/wecom/wecom_test.go
@@ -190,7 +190,7 @@ func TestSend_StreamFailureFallsBackToActualChatID(t *testing.T) {
return wecomTestAck(nil), nil
}
- if err := ch.Send(context.Background(), bus.OutboundMessage{
+ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: "hello",
@@ -247,7 +247,7 @@ func TestSend_DoesNotSplitStreamReply(t *testing.T) {
}
content := strings.Repeat("\u4e2d", 30000)
- if err := ch.Send(context.Background(), bus.OutboundMessage{
+ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: content,
@@ -283,7 +283,7 @@ func TestSend_DoesNotSplitActivePush(t *testing.T) {
}
content := strings.Repeat("a", 30000)
- if err := ch.Send(context.Background(), bus.OutboundMessage{
+ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: content,
@@ -346,7 +346,7 @@ func TestSendMedia_SendsActiveImage(t *testing.T) {
}
}
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
Channel: "wecom",
ChatID: "chat-1",
Parts: []bus.MediaPart{{
@@ -457,7 +457,7 @@ func TestSendMedia_UsesTurnImageAndFinishesStream(t *testing.T) {
}
}
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
Channel: "wecom",
ChatID: "chat-1",
Parts: []bus.MediaPart{{
@@ -553,7 +553,7 @@ func TestSendMedia_SendsActiveFile(t *testing.T) {
}
}
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
Channel: "wecom",
ChatID: "chat-2",
Parts: []bus.MediaPart{{
diff --git a/pkg/channels/weixin/media.go b/pkg/channels/weixin/media.go
index 4da7f0db9..cf1b45612 100644
--- a/pkg/channels/weixin/media.go
+++ b/pkg/channels/weixin/media.go
@@ -1097,12 +1097,12 @@ func (c *WeixinChannel) StartTyping(ctx context.Context, chatID string) (func(),
}
// SendMedia implements channels.MediaSender.
-func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
if !c.IsRunning() {
- return basechannels.ErrNotRunning
+ return nil, basechannels.ErrNotRunning
}
if err := c.ensureSessionActive(); err != nil {
- return err
+ return nil, err
}
contextToken := ""
@@ -1110,7 +1110,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
contextToken, _ = v.(string)
}
if contextToken == "" {
- return fmt.Errorf(
+ return nil, fmt.Errorf(
"weixin send media: missing context token for chat %s: %w",
msg.ChatID,
basechannels.ErrSendFailed,
@@ -1125,7 +1125,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
"ref": part.Ref,
"error": err.Error(),
})
- return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
+ return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
}
func() {
if cleanup != nil {
@@ -1147,11 +1147,11 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
"error": err.Error(),
})
if c.remainingPause() > 0 {
- return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
+ return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
}
- return fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary)
+ return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary)
}
}
- return nil
+ return nil, nil
}
diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go
index 65fabe399..0e9010131 100644
--- a/pkg/channels/weixin/weixin.go
+++ b/pkg/channels/weixin/weixin.go
@@ -358,16 +358,16 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
}
// Send implements channels.Channel by sending a text message to the WeChat user.
-func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
if err := c.ensureSessionActive(); err != nil {
- return err
+ return nil, err
}
if msg.Content == "" {
- return nil
+ return nil, nil
}
// We need a context_token to send a reply. It should be stored in the conversation metadata.
@@ -386,7 +386,7 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
logger.ErrorCF("weixin", "Missing context token, cannot send message", map[string]any{
"to_user_id": toUserID,
})
- return fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID)
+ return nil, fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID)
}
if err := c.sendTextMessage(ctx, toUserID, contextToken, msg.Content); err != nil {
@@ -395,10 +395,10 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
"error": err.Error(),
})
if c.remainingPause() > 0 {
- return fmt.Errorf("weixin send: %w", channels.ErrSendFailed)
+ return nil, fmt.Errorf("weixin send: %w", channels.ErrSendFailed)
}
- return fmt.Errorf("weixin send: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("weixin send: %w", channels.ErrTemporary)
}
- return nil
+ return nil, nil
}
diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go
index 70b3e02bf..98622fe37 100644
--- a/pkg/channels/whatsapp/whatsapp.go
+++ b/pkg/channels/whatsapp/whatsapp.go
@@ -104,15 +104,15 @@ func (c *WhatsAppChannel) Stop(ctx context.Context) error {
return nil
}
-func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
// Check ctx before acquiring lock
select {
case <-ctx.Done():
- return ctx.Err()
+ return nil, ctx.Err()
default:
}
@@ -120,7 +120,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
defer c.mu.Unlock()
if c.conn == nil {
- return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
}
payload := map[string]any{
@@ -131,17 +131,17 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
data, err := json.Marshal(payload)
if err != nil {
- return fmt.Errorf("failed to marshal message: %w", err)
+ return nil, fmt.Errorf("failed to marshal message: %w", err)
}
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
_ = c.conn.SetWriteDeadline(time.Time{})
- return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
}
_ = c.conn.SetWriteDeadline(time.Time{})
- return nil
+ return nil, nil
}
func (c *WhatsAppChannel) listen() {
diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go
index 188a7c8fa..d0a74a405 100644
--- a/pkg/channels/whatsapp_native/whatsapp_native.go
+++ b/pkg/channels/whatsapp_native/whatsapp_native.go
@@ -396,13 +396,13 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
}
-func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
select {
case <-ctx.Done():
- return ctx.Err()
+ return nil, ctx.Err()
default:
}
@@ -411,18 +411,18 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
c.mu.Unlock()
if client == nil || !client.IsConnected() {
- return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
}
// Detect unpaired state: the client is connected (to WhatsApp servers)
// but has not completed QR-login yet, so sending would fail.
if client.Store.ID == nil {
- return fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary)
}
to, err := parseJID(msg.ChatID)
if err != nil {
- return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)
+ return nil, fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)
}
waMsg := &waE2E.Message{
@@ -430,9 +430,9 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
}
if _, err = client.SendMessage(ctx, to, waMsg); err != nil {
- return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
}
- return nil
+ return nil, nil
}
// parseJID converts a chat ID (phone number or JID string) to types.JID.
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 87cb31f9e..397cd4ab8 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -636,13 +636,6 @@ func (c *ModelConfig) SetAPIKey(value string) {
}
}
-type GatewayConfig struct {
- Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
- Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
- HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
- LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
-}
-
type ToolDiscoveryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"`
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 6734257f4..278dfa43a 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -1418,6 +1418,38 @@ func TestConfigLogLevelEmpty(t *testing.T) {
}
}
+func TestResolveGatewayLogLevel(t *testing.T) {
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.json")
+ data := `{"version":1,"gateway":{"log_level":"debug"}}`
+ if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
+ t.Fatalf("setup: %v", err)
+ }
+
+ if got := ResolveGatewayLogLevel(cfgPath); got != "debug" {
+ t.Fatalf("ResolveGatewayLogLevel() = %q, want %q", got, "debug")
+ }
+}
+
+func TestResolveGatewayLogLevel_UsesEnvOverrideAndNormalizesInvalid(t *testing.T) {
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.json")
+ data := `{"version":1,"gateway":{"log_level":"debug"}}`
+ if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
+ t.Fatalf("setup: %v", err)
+ }
+
+ t.Setenv("PICOCLAW_LOG_LEVEL", "warning")
+ if got := ResolveGatewayLogLevel(cfgPath); got != "warn" {
+ t.Fatalf("ResolveGatewayLogLevel() with env override = %q, want %q", got, "warn")
+ }
+
+ t.Setenv("PICOCLAW_LOG_LEVEL", "garbage")
+ if got := ResolveGatewayLogLevel(cfgPath); got != DefaultGatewayLogLevel {
+ t.Fatalf("ResolveGatewayLogLevel() with invalid env override = %q, want %q", got, DefaultGatewayLogLevel)
+ }
+}
+
func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index bded97fcd..c3845e3e2 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -347,7 +347,7 @@ func DefaultConfig() *Config {
Host: "127.0.0.1",
Port: 18790,
HotReload: false,
- LogLevel: "warn",
+ LogLevel: DefaultGatewayLogLevel,
},
Tools: ToolsConfig{
FilterSensitiveData: true,
diff --git a/pkg/config/gateway.go b/pkg/config/gateway.go
new file mode 100644
index 000000000..e9f4085d3
--- /dev/null
+++ b/pkg/config/gateway.go
@@ -0,0 +1,72 @@
+package config
+
+import (
+ "encoding/json"
+ "os"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+const DefaultGatewayLogLevel = "warn"
+
+type GatewayConfig struct {
+ Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
+ Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
+ HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
+ LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
+}
+
+func canonicalGatewayLogLevel(level logger.LogLevel) string {
+ switch level {
+ case logger.DEBUG:
+ return "debug"
+ case logger.INFO:
+ return "info"
+ case logger.WARN:
+ return "warn"
+ case logger.ERROR:
+ return "error"
+ case logger.FATAL:
+ return "fatal"
+ default:
+ return DefaultGatewayLogLevel
+ }
+}
+
+func normalizeGatewayLogLevel(logLevel string) string {
+ if level, ok := logger.ParseLevel(logLevel); ok {
+ return canonicalGatewayLogLevel(level)
+ }
+ return DefaultGatewayLogLevel
+}
+
+// EffectiveGatewayLogLevel returns the normalized runtime log level from a loaded config.
+// Invalid or empty values fall back to the package default.
+func EffectiveGatewayLogLevel(cfg *Config) string {
+ if cfg == nil {
+ return DefaultGatewayLogLevel
+ }
+ return normalizeGatewayLogLevel(cfg.Gateway.LogLevel)
+}
+
+// ResolveGatewayLogLevel reads the configured gateway log level without triggering
+// the full config loader, so startup code can apply logging before config load logs run.
+// The PICOCLAW_LOG_LEVEL environment variable overrides the file value.
+func ResolveGatewayLogLevel(path string) string {
+ cfg := struct {
+ Gateway GatewayConfig `json:"gateway"`
+ }{
+ Gateway: GatewayConfig{LogLevel: DefaultGatewayLogLevel},
+ }
+
+ data, err := os.ReadFile(path)
+ if err == nil {
+ _ = json.Unmarshal(data, &cfg)
+ }
+
+ if envLevel := os.Getenv("PICOCLAW_LOG_LEVEL"); envLevel != "" {
+ cfg.Gateway.LogLevel = envLevel
+ }
+
+ return normalizeGatewayLogLevel(cfg.Gateway.LogLevel)
+}
diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go
index a47bf2ac6..64aed5e8c 100644
--- a/pkg/gateway/gateway.go
+++ b/pkg/gateway/gateway.go
@@ -98,6 +98,12 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
}
defer logger.DisableFileLogging()
+ if debug {
+ logger.SetLevel(logger.DEBUG)
+ } else {
+ logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath))
+ }
+
cfg, err := config.LoadConfig(configPath)
if err != nil {
logger.Fatalf("error loading config: %v", err)
@@ -109,11 +115,11 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
// Debug mode permanently overrides the config log level to DEBUG.
if debug {
- logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
} else {
- logger.SetLevelFromString(cfg.Gateway.LogLevel)
- logger.Infof("Log level set to %q", cfg.Gateway.LogLevel)
+ effectiveLogLevel := config.EffectiveGatewayLogLevel(cfg)
+ logger.SetLevelFromString(effectiveLogLevel)
+ logger.Infof("Log level set to %q", effectiveLogLevel)
}
// Enforce singleton: write PID file with generated token.
@@ -476,8 +482,9 @@ func handleConfigReload(
// Debug mode permanently overrides the config log level to DEBUG.
if !debug {
// Update log level last so that reload-related info/warn logs above are not suppressed.
- logger.SetLevelFromString(newCfg.Gateway.LogLevel)
- logger.Infof("Log level changing from current to %q", newCfg.Gateway.LogLevel)
+ effectiveLogLevel := config.EffectiveGatewayLogLevel(newCfg)
+ logger.SetLevelFromString(effectiveLogLevel)
+ logger.Infof("Log level changing from current to %q", effectiveLogLevel)
}
return nil
diff --git a/pkg/providers/bedrock/provider_bedrock.go b/pkg/providers/bedrock/provider_bedrock.go
index 9ca29455f..3798c5fd8 100644
--- a/pkg/providers/bedrock/provider_bedrock.go
+++ b/pkg/providers/bedrock/provider_bedrock.go
@@ -208,7 +208,10 @@ func (p *Provider) Chat(
if err != nil {
// Check for SSO token expiration errors and provide actionable guidance
if isSSOTokenError(err) {
- return nil, fmt.Errorf("bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w", err)
+ return nil, fmt.Errorf(
+ "bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w",
+ err,
+ )
}
return nil, fmt.Errorf("bedrock converse: %w", err)
}
diff --git a/pkg/providers/bedrock/provider_bedrock_test.go b/pkg/providers/bedrock/provider_bedrock_test.go
index 882c2971c..38a5e26da 100644
--- a/pkg/providers/bedrock/provider_bedrock_test.go
+++ b/pkg/providers/bedrock/provider_bedrock_test.go
@@ -583,13 +583,17 @@ func TestIsSSOTokenError(t *testing.T) {
expected: true,
},
{
- name: "full SSO error message",
- err: fmt.Errorf("get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token"),
+ name: "full SSO error message",
+ err: fmt.Errorf(
+ "get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token",
+ ),
expected: true,
},
{
- name: "SSO token file missing",
- err: fmt.Errorf("get identity: get credentials: failed to refresh cached credentials, failed to read cached SSO token file, open ~/.aws/sso/cache/abc123.json: no such file or directory"),
+ name: "SSO token file missing",
+ err: fmt.Errorf(
+ "get identity: get credentials: failed to refresh cached credentials, failed to read cached SSO token file, open ~/.aws/sso/cache/abc123.json: no such file or directory",
+ ),
expected: true,
},
}
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index 962e6ae19..e956db209 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -17,6 +17,48 @@ import (
"github.com/sipeed/picoclaw/pkg/providers/bedrock"
)
+type protocolMeta struct {
+ defaultAPIBase string
+ emptyAPIKeyAllowed bool
+}
+
+var protocolMetaByName = map[string]protocolMeta{
+ "openai": {defaultAPIBase: "https://api.openai.com/v1"},
+ "openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"},
+ "litellm": {defaultAPIBase: "http://localhost:4000/v1"},
+ "lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true},
+ "novita": {defaultAPIBase: "https://api.novita.ai/openai"},
+ "groq": {defaultAPIBase: "https://api.groq.com/openai/v1"},
+ "zhipu": {defaultAPIBase: "https://open.bigmodel.cn/api/paas/v4"},
+ "gemini": {defaultAPIBase: "https://generativelanguage.googleapis.com/v1beta"},
+ "nvidia": {defaultAPIBase: "https://integrate.api.nvidia.com/v1"},
+ "ollama": {defaultAPIBase: "http://localhost:11434/v1", emptyAPIKeyAllowed: true},
+ "moonshot": {defaultAPIBase: "https://api.moonshot.cn/v1"},
+ "shengsuanyun": {defaultAPIBase: "https://router.shengsuanyun.com/api/v1"},
+ "deepseek": {defaultAPIBase: "https://api.deepseek.com/v1"},
+ "cerebras": {defaultAPIBase: "https://api.cerebras.ai/v1"},
+ "vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"},
+ "volcengine": {defaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3"},
+ "qwen": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"},
+ "qwen-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
+ "qwen-international": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
+ "dashscope-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
+ "qwen-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"},
+ "dashscope-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"},
+ "coding-plan": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
+ "alibaba-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
+ "qwen-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
+ "coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"},
+ "alibaba-coding-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"},
+ "vllm": {defaultAPIBase: "http://localhost:8000/v1", emptyAPIKeyAllowed: true},
+ "mistral": {defaultAPIBase: "https://api.mistral.ai/v1"},
+ "avian": {defaultAPIBase: "https://api.avian.io/v1"},
+ "minimax": {defaultAPIBase: "https://api.minimaxi.com/v1"},
+ "longcat": {defaultAPIBase: "https://api.longcat.chat/openai"},
+ "modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"},
+ "mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"},
+}
+
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
func createClaudeAuthProvider() (LLMProvider, error) {
cred, err := getCredential("anthropic")
@@ -154,13 +196,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
}
return provider, modelID, nil
- case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
+ case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding", "mimo":
// All other OpenAI-compatible HTTP providers
- if cfg.APIKey() == "" && cfg.APIBase == "" {
+ if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
}
apiBase := cfg.APIBase
@@ -294,64 +336,30 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
}
}
+func isEmptyAPIKeyAllowed(protocol string) bool {
+ meta, ok := protocolMetaByName[protocol]
+ return ok && meta.emptyAPIKeyAllowed
+}
+
+// IsEmptyAPIKeyAllowedForProtocol reports whether a protocol allows requests
+// without api_key when using its default local endpoint.
+func IsEmptyAPIKeyAllowedForProtocol(protocol string) bool {
+ protocol = strings.ToLower(strings.TrimSpace(protocol))
+ return isEmptyAPIKeyAllowed(protocol)
+}
+
+// DefaultAPIBaseForProtocol returns the configured default API base for a protocol.
+// It returns empty string if the protocol has no default base.
+func DefaultAPIBaseForProtocol(protocol string) string {
+ protocol = strings.ToLower(strings.TrimSpace(protocol))
+ return getDefaultAPIBase(protocol)
+}
+
// getDefaultAPIBase returns the default API base URL for a given protocol.
func getDefaultAPIBase(protocol string) string {
- switch protocol {
- case "openai":
- return "https://api.openai.com/v1"
- case "openrouter":
- return "https://openrouter.ai/api/v1"
- case "litellm":
- return "http://localhost:4000/v1"
- case "novita":
- return "https://api.novita.ai/openai"
- case "groq":
- return "https://api.groq.com/openai/v1"
- case "zhipu":
- return "https://open.bigmodel.cn/api/paas/v4"
- case "gemini":
- return "https://generativelanguage.googleapis.com/v1beta"
- case "nvidia":
- return "https://integrate.api.nvidia.com/v1"
- case "ollama":
- return "http://localhost:11434/v1"
- case "moonshot":
- return "https://api.moonshot.cn/v1"
- case "shengsuanyun":
- return "https://router.shengsuanyun.com/api/v1"
- case "deepseek":
- return "https://api.deepseek.com/v1"
- case "cerebras":
- return "https://api.cerebras.ai/v1"
- case "vivgrid":
- return "https://api.vivgrid.com/v1"
- case "volcengine":
- return "https://ark.cn-beijing.volces.com/api/v3"
- case "qwen":
- return "https://dashscope.aliyuncs.com/compatible-mode/v1"
- case "qwen-intl", "qwen-international", "dashscope-intl":
- return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
- case "qwen-us", "dashscope-us":
- return "https://dashscope-us.aliyuncs.com/compatible-mode/v1"
- case "coding-plan", "alibaba-coding", "qwen-coding":
- return "https://coding-intl.dashscope.aliyuncs.com/v1"
- case "coding-plan-anthropic", "alibaba-coding-anthropic":
- return "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
- case "vllm":
- return "http://localhost:8000/v1"
- case "mistral":
- return "https://api.mistral.ai/v1"
- case "avian":
- return "https://api.avian.io/v1"
- case "minimax":
- return "https://api.minimaxi.com/v1"
- case "longcat":
- return "https://api.longcat.chat/openai"
- case "modelscope":
- return "https://api-inference.modelscope.cn/v1"
- case "mimo":
- return "https://api.xiaomimimo.com/v1"
- default:
+ meta, ok := protocolMetaByName[protocol]
+ if !ok {
return ""
}
+ return meta.defaultAPIBase
}
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index f1fe02cc2..588b81650 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -121,6 +121,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"vllm", "vllm"},
{"deepseek", "deepseek"},
{"ollama", "ollama"},
+ {"lmstudio", "lmstudio"},
{"longcat", "longcat"},
{"modelscope", "modelscope"},
{"mimo", "mimo"},
@@ -153,6 +154,12 @@ func TestGetDefaultAPIBase_LiteLLM(t *testing.T) {
}
}
+func TestGetDefaultAPIBase_LMStudio(t *testing.T) {
+ if got := getDefaultAPIBase("lmstudio"); got != "http://localhost:1234/v1" {
+ t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "lmstudio", got, "http://localhost:1234/v1")
+ }
+}
+
func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-litellm",
@@ -173,6 +180,85 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
}
}
+func TestCreateProviderFromConfig_LocalProviders(t *testing.T) {
+ tests := []struct {
+ name string
+ modelName string
+ model string
+ apiKey string
+ wantModelID string
+ }{
+ {
+ name: "LMStudio with API key",
+ modelName: "test-lmstudio",
+ model: "lmstudio/openai/gpt-oss-20b",
+ apiKey: "test-key",
+ wantModelID: "openai/gpt-oss-20b",
+ },
+ {
+ name: "LMStudio without API key",
+ modelName: "test-lmstudio",
+ model: "lmstudio/openai/gpt-oss-20b",
+ apiKey: "",
+ wantModelID: "openai/gpt-oss-20b",
+ },
+ {
+ name: "Ollama with API key",
+ modelName: "test-ollama",
+ model: "ollama/llama3.1:8b",
+ apiKey: "test-key",
+ wantModelID: "llama3.1:8b",
+ },
+ {
+ name: "Ollama without API key",
+ modelName: "test-ollama",
+ model: "ollama/llama3.1:8b",
+ apiKey: "",
+ wantModelID: "llama3.1:8b",
+ },
+ {
+ name: "VLLM with API key",
+ modelName: "test-vllm",
+ model: "vllm/Qwen/Qwen3-8B",
+ apiKey: "test-key",
+ wantModelID: "Qwen/Qwen3-8B",
+ },
+ {
+ name: "VLLM without API key",
+ modelName: "test-vllm",
+ model: "vllm/Qwen/Qwen3-8B",
+ apiKey: "",
+ wantModelID: "Qwen/Qwen3-8B",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: tt.modelName,
+ Model: tt.model,
+ }
+ if tt.apiKey != "" {
+ cfg.SetAPIKey(tt.apiKey)
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != tt.wantModelID {
+ t.Errorf("modelID = %q, want %q", modelID, tt.wantModelID)
+ }
+ if _, ok := provider.(*HTTPProvider); !ok {
+ t.Fatalf("expected *HTTPProvider, got %T", provider)
+ }
+ })
+ }
+}
+
func TestCreateProviderFromConfig_LongCat(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-longcat",
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
index e2d1d7d98..472c14257 100644
--- a/pkg/providers/github_copilot_provider.go
+++ b/pkg/providers/github_copilot_provider.go
@@ -41,9 +41,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi
}
session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{
- Model: model,
+ Model: model,
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
- Hooks: &copilot.SessionHooks{},
+ Hooks: &copilot.SessionHooks{},
})
if err != nil {
client.Stop()
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index 90bc683b8..aa9473731 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -42,6 +42,23 @@ type Option func(*Provider)
const defaultRequestTimeout = common.DefaultRequestTimeout
+var stripModelPrefixProviders = map[string]struct{}{
+ "litellm": {},
+ "moonshot": {},
+ "nvidia": {},
+ "groq": {},
+ "ollama": {},
+ "deepseek": {},
+ "google": {},
+ "openrouter": {},
+ "zhipu": {},
+ "mistral": {},
+ "vivgrid": {},
+ "minimax": {},
+ "novita": {},
+ "lmstudio": {},
+}
+
func WithMaxTokensField(maxTokensField string) Option {
return func(p *Provider) {
p.maxTokensField = maxTokensField
@@ -397,13 +414,11 @@ func normalizeModel(model, apiBase string) string {
}
prefix := strings.ToLower(before)
- switch prefix {
- case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google",
- "openrouter", "zhipu", "mistral", "vivgrid", "minimax", "novita":
+ if _, ok := stripModelPrefixProviders[prefix]; ok {
return after
- default:
- return model
}
+
+ return model
}
func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any {
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
index ab632ccf3..823b0ff28 100644
--- a/pkg/providers/openai_compat/provider_test.go
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -432,7 +432,7 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin
}
}
-func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T) {
+func TestProviderChat_StripsKnownProviderPrefixes(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -474,6 +474,11 @@ func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T
input: "ollama/qwen2.5:14b",
wantModel: "qwen2.5:14b",
},
+ {
+ name: "strips lmstudio prefix and keeps nested model",
+ input: "lmstudio/openai/gpt-oss-20b",
+ wantModel: "openai/gpt-oss-20b",
+ },
{
name: "strips deepseek prefix",
input: "deepseek/deepseek-chat",
@@ -579,6 +584,9 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" {
t.Fatalf("normalizeModel(deepseek) = %q, want %q", got, "deepseek-chat")
}
+ if got := normalizeModel("lmstudio/openai/gpt-oss-20b", "http://localhost:1234/v1"); got != "openai/gpt-oss-20b" {
+ t.Fatalf("normalizeModel(lmstudio) = %q, want %q", got, "openai/gpt-oss-20b")
+ }
if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" {
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
}
diff --git a/web/backend/api/config.go b/web/backend/api/config.go
index 7c8e21308..5490b4e18 100644
--- a/web/backend/api/config.go
+++ b/web/backend/api/config.go
@@ -20,6 +20,14 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
}
+func (h *Handler) applyRuntimeLogLevel() {
+ if h.debug {
+ logger.SetLevel(logger.DEBUG)
+ return
+ }
+ logger.SetLevelFromString(config.ResolveGatewayLogLevel(h.configPath))
+}
+
// handleGetConfig returns the complete system configuration.
//
// GET /api/config
@@ -80,8 +88,6 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
return
}
- logger.Infof("configuration updated successfully")
-
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
@@ -89,6 +95,8 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
// Refresh cached pico token in case user changed it.
refreshPicoToken(&cfg)
+ h.applyRuntimeLogLevel()
+ logger.Infof("configuration updated successfully")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
@@ -133,7 +141,6 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
-
existing, err := json.Marshal(cfg)
if err != nil {
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
@@ -187,6 +194,8 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
// Refresh cached pico token in case user changed it.
refreshPicoToken(&newCfg)
+ h.applyRuntimeLogLevel()
+ logger.Infof("configuration updated successfully")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go
index d3e25a7f9..a90145f3c 100644
--- a/web/backend/api/config_test.go
+++ b/web/backend/api/config_test.go
@@ -9,8 +9,38 @@ import (
"testing"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
)
+func assertGatewayLogLevelApplied(t *testing.T, method, body string, want logger.LogLevel) {
+ t.Helper()
+
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ initialLevel := logger.GetLevel()
+ logger.SetLevel(logger.INFO)
+ t.Cleanup(func() {
+ logger.SetLevel(initialLevel)
+ })
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(method, "/api/config", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s /api/config status = %d, want %d, body=%s", method, rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if got := logger.GetLevel(); got != want {
+ t.Fatalf("logger.GetLevel() = %v, want %v", got, want)
+ }
+}
+
func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -251,6 +281,68 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
}
}
+func TestHandleUpdateConfig_AppliesGatewayLogLevel(t *testing.T) {
+ assertGatewayLogLevelApplied(t, http.MethodPut, `{
+ "version": 1,
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model_name": "custom-default"
+ }
+ },
+ "gateway": {
+ "log_level": "error"
+ },
+ "model_list": [
+ {
+ "model_name": "custom-default",
+ "model": "openai/gpt-4o",
+ "api_keys": ["sk-default"]
+ }
+ ]
+ }`, logger.ERROR)
+}
+
+func TestHandlePatchConfig_AppliesGatewayLogLevel(t *testing.T) {
+ assertGatewayLogLevelApplied(t, http.MethodPatch, `{
+ "gateway": {
+ "log_level": "debug"
+ }
+ }`, logger.DEBUG)
+}
+
+func TestHandlePatchConfig_PreservesDebugFlagOverride(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ initialLevel := logger.GetLevel()
+ logger.SetLevel(logger.INFO)
+ t.Cleanup(func() {
+ logger.SetLevel(initialLevel)
+ })
+
+ h := NewHandler(configPath)
+ h.SetDebug(true)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "gateway": {
+ "log_level": "error"
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if got := logger.GetLevel(); got != logger.DEBUG {
+ t.Fatalf("logger.GetLevel() = %v, want %v", got, logger.DEBUG)
+ }
+}
+
func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go
index ce3a9ca1e..6f5f5dd5d 100644
--- a/web/backend/api/gateway.go
+++ b/web/backend/api/gateway.go
@@ -59,6 +59,24 @@ func refreshPicoTokensLocked(configPath string) {
gateway.picoToken = cfg.Channels.Pico.Token.String()
}
+// ensurePicoTokenCachedLocked lazily fills the in-memory pico token cache when
+// the launcher has already discovered a running gateway via pidData, but has
+// not yet refreshed the token into memory.
+func ensurePicoTokenCachedLocked(configPath string) {
+ if gateway.picoToken != "" {
+ return
+ }
+ refreshPicoTokensLocked(configPath)
+}
+
+func (h *Handler) gatewayCommandArgs() []string {
+ args := []string{"gateway", "-E"}
+ if h.debug {
+ args = append(args, "-d")
+ }
+ return args
+}
+
const (
protocolKey = "Sec-Websocket-Protocol"
tokenPrefix = "token."
@@ -521,7 +539,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
execPath := utils.FindPicoclawBinary()
logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath))
- cmd = exec.Command(execPath, "gateway", "-E")
+ cmd = exec.Command(execPath, h.gatewayCommandArgs()...)
cmd.Env = os.Environ()
// Forward the launcher's config path via the environment variable that
// GetConfigPath() already reads, so the gateway sub-process uses the same
diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go
index 6190f0c7c..f8e8eadba 100644
--- a/web/backend/api/gateway_host.go
+++ b/web/backend/api/gateway_host.go
@@ -190,12 +190,20 @@ func joinClientVisibleHostPort(r *http.Request, host string, serverListenPort in
func (h *Handler) picoWebUIAddr(r *http.Request) string {
wsPort := h.serverPort
if wsPort == 0 {
- wsPort = 18800 // default web server port
+ wsPort = 18800
}
if fwdHost := forwardedHostFirst(r); fwdHost != "" {
return joinClientVisibleHostPort(r, fwdHost, wsPort)
}
host := requestHostName(r)
+ // Use clientVisiblePort only when an explicit port is present in headers
+ // or Host header — do not infer from TLS/scheme, as serverPort takes priority.
+ if p := forwardedPortFirst(r); p != "" {
+ return net.JoinHostPort(host, p)
+ }
+ if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" {
+ return net.JoinHostPort(host, port)
+ }
return net.JoinHostPort(host, strconv.Itoa(wsPort))
}
diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go
index ca6639f67..fc8ee13f3 100644
--- a/web/backend/api/gateway_test.go
+++ b/web/backend/api/gateway_test.go
@@ -68,6 +68,7 @@ func resetGatewayTestState(t *testing.T) {
originalRestartGracePeriod := gatewayRestartGracePeriod
originalRestartForceKillWindow := gatewayRestartForceKillWindow
originalRestartPollInterval := gatewayRestartPollInterval
+ t.Setenv("PICOCLAW_HOME", t.TempDir())
t.Cleanup(func() {
gatewayHealthGet = originalHealthGet
gatewayRestartGracePeriod = originalRestartGracePeriod
@@ -76,6 +77,8 @@ func resetGatewayTestState(t *testing.T) {
gateway.mu.Lock()
gateway.cmd = nil
+ gateway.pidData = nil
+ gateway.owned = false
gateway.bootDefaultModel = ""
gateway.bootConfigSignature = ""
setGatewayRuntimeStatusLocked("stopped")
@@ -165,6 +168,17 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
}
}
+func TestGatewayCommandArgsIncludesDebugFlagWhenEnabled(t *testing.T) {
+ h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
+ h.SetDebug(true)
+
+ args := h.gatewayCommandArgs()
+ want := []string{"gateway", "-E", "-d"}
+ if strings.Join(args, " ") != strings.Join(want, " ") {
+ t.Fatalf("gatewayCommandArgs() = %v, want %v", args, want)
+ }
+}
+
func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go
index aeef85119..160c4d257 100644
--- a/web/backend/api/model_status.go
+++ b/web/backend/api/model_status.go
@@ -10,10 +10,22 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
)
const modelProbeTimeout = 800 * time.Millisecond
+const (
+ modelStatusAvailable = "available"
+ modelStatusUnconfigured = "unconfigured"
+ modelStatusUnreachable = "unreachable"
+)
+
+type modelConfigurationSummary struct {
+ Available bool
+ Status string
+}
+
var (
probeTCPServiceFunc = probeTCPService
probeOllamaModelFunc = probeOllamaModel
@@ -42,16 +54,17 @@ func hasModelConfiguration(m *config.ModelConfig) bool {
return apiKey != ""
}
-// isModelConfigured reports whether a model is currently available to use.
-// Local models must be reachable; remote/API-key models only need saved config.
-func isModelConfigured(m *config.ModelConfig) bool {
+func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary {
if !hasModelConfiguration(m) {
- return false
+ return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured}
}
if requiresRuntimeProbe(m) {
- return probeLocalModelAvailability(m)
+ if probeLocalModelAvailability(m) {
+ return modelConfigurationSummary{Available: true, Status: modelStatusAvailable}
+ }
+ return modelConfigurationSummary{Available: false, Status: modelStatusUnreachable}
}
- return true
+ return modelConfigurationSummary{Available: true, Status: modelStatusAvailable}
}
func requiresRuntimeProbe(m *config.ModelConfig) bool {
@@ -60,10 +73,14 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
return true
}
- switch modelProtocol(m.Model) {
+ protocol := modelProtocol(m.Model)
+
+ switch protocol {
case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot":
return true
- case "ollama", "vllm":
+ }
+
+ if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
apiBase := strings.TrimSpace(m.APIBase)
return apiBase == "" || hasLocalAPIBase(apiBase)
}
@@ -81,7 +98,7 @@ func probeLocalModelAvailability(m *config.ModelConfig) bool {
switch protocol {
case "ollama":
return probeOllamaModelFunc(apiBase, modelID)
- case "vllm":
+ case "vllm", "lmstudio":
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
case "github-copilot", "copilot":
return probeTCPServiceFunc(apiBase)
@@ -100,11 +117,12 @@ func modelProbeAPIBase(m *config.ModelConfig) string {
return normalizeModelProbeAPIBase(apiBase)
}
- switch modelProtocol(m.Model) {
- case "ollama":
- return "http://localhost:11434/v1"
- case "vllm":
- return "http://localhost:8000/v1"
+ protocol := modelProtocol(m.Model)
+ if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
+ return providers.DefaultAPIBaseForProtocol(protocol)
+ }
+
+ switch protocol {
case "github-copilot", "copilot":
return "localhost:4321"
default:
diff --git a/web/backend/api/model_status_test.go b/web/backend/api/model_status_test.go
index df942a9e9..bfeadf1fe 100644
--- a/web/backend/api/model_status_test.go
+++ b/web/backend/api/model_status_test.go
@@ -35,3 +35,53 @@ func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T
t.Fatal("probeLocalModelAvailability() = false, want true when api_key is configured")
}
}
+
+func TestRequiresRuntimeProbe_LMStudio(t *testing.T) {
+ if !requiresRuntimeProbe(&config.ModelConfig{
+ Model: "lmstudio/openai/gpt-oss-20b",
+ }) {
+ t.Fatal("requiresRuntimeProbe(lmstudio with default base) = false, want true")
+ }
+
+ if requiresRuntimeProbe(&config.ModelConfig{
+ Model: "lmstudio/openai/gpt-oss-20b",
+ APIBase: "https://api.example.com/v1",
+ }) {
+ t.Fatal("requiresRuntimeProbe(lmstudio with remote base) = true, want false")
+ }
+}
+
+func TestModelProbeAPIBase_LMStudioDefault(t *testing.T) {
+ got := modelProbeAPIBase(&config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"})
+ if got != "http://localhost:1234/v1" {
+ t.Fatalf("modelProbeAPIBase(lmstudio) = %q, want %q", got, "http://localhost:1234/v1")
+ }
+}
+
+func TestProbeLocalModelAvailability_LMStudioUsesOpenAICompatibleProbe(t *testing.T) {
+ originalProbe := probeOpenAICompatibleModelFunc
+ defer func() { probeOpenAICompatibleModelFunc = originalProbe }()
+
+ called := false
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ called = true
+ if apiBase != "http://localhost:1234/v1" {
+ t.Fatalf("apiBase = %q, want %q", apiBase, "http://localhost:1234/v1")
+ }
+ if modelID != "openai/gpt-oss-20b" {
+ t.Fatalf("modelID = %q, want %q", modelID, "openai/gpt-oss-20b")
+ }
+ if apiKey != "" {
+ t.Fatalf("apiKey = %q, want empty", apiKey)
+ }
+ return true
+ }
+
+ model := &config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"}
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("probeLocalModelAvailability(lmstudio) = false, want true")
+ }
+ if !called {
+ t.Fatal("probeOpenAICompatibleModelFunc was not called for lmstudio")
+ }
+}
diff --git a/web/backend/api/models.go b/web/backend/api/models.go
index fd3cd85b7..e6749b56e 100644
--- a/web/backend/api/models.go
+++ b/web/backend/api/models.go
@@ -40,10 +40,11 @@ type modelResponse struct {
ThinkingLevel string `json:"thinking_level,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitempty"`
// Meta
- Enabled bool `json:"enabled"`
- Configured bool `json:"configured"`
- IsDefault bool `json:"is_default"`
- IsVirtual bool `json:"is_virtual"`
+ Enabled bool `json:"enabled"`
+ Available bool `json:"available"`
+ Status string `json:"status"`
+ IsDefault bool `json:"is_default"`
+ IsVirtual bool `json:"is_virtual"`
}
// handleListModels returns all model_list entries with masked API keys.
@@ -57,14 +58,14 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
}
defaultModel := cfg.Agents.Defaults.GetModelName()
- configured := make([]bool, len(cfg.ModelList))
+ modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList))
var wg sync.WaitGroup
wg.Add(len(cfg.ModelList))
for i, m := range cfg.ModelList {
go func(i int, m *config.ModelConfig) {
defer wg.Done()
- configured[i] = isModelConfigured(m)
+ modelStatuses[i] = modelConfigurationStatus(m)
}(i, m)
}
wg.Wait()
@@ -87,7 +88,8 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
ThinkingLevel: m.ThinkingLevel,
ExtraBody: m.ExtraBody,
Enabled: m.Enabled,
- Configured: configured[i],
+ Available: modelStatuses[i].Available,
+ Status: modelStatuses[i].Status,
IsDefault: m.ModelName == defaultModel,
IsVirtual: m.IsVirtual(),
})
diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go
index 97f153a80..e78de1606 100644
--- a/web/backend/api/models_test.go
+++ b/web/backend/api/models_test.go
@@ -27,7 +27,7 @@ func resetModelProbeHooks(t *testing.T) {
})
}
-func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *testing.T) {
+func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
@@ -113,25 +113,42 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
t.Fatalf("Unmarshal() error = %v", err)
}
- got := make(map[string]bool, len(resp.Models))
+ gotAvailable := make(map[string]bool, len(resp.Models))
+ gotStatus := make(map[string]string, len(resp.Models))
for _, model := range resp.Models {
- got[model.ModelName] = model.Configured
+ gotAvailable[model.ModelName] = model.Available
+ gotStatus[model.ModelName] = model.Status
}
- if got["openai-oauth"] {
- t.Fatalf("openai oauth model configured = true, want false without stored credential")
+ if gotAvailable["openai-oauth"] {
+ t.Fatalf("openai oauth model available = true, want false without stored credential")
}
- if !got["vllm-local"] {
- t.Fatalf("vllm local model configured = false, want true when local probe succeeds")
+ if !gotAvailable["vllm-local"] {
+ t.Fatalf("vllm local model available = false, want true when local probe succeeds")
}
- if !got["ollama-default"] {
- t.Fatalf("ollama default model configured = false, want true when default local probe succeeds")
+ if !gotAvailable["ollama-default"] {
+ t.Fatalf("ollama default model available = false, want true when default local probe succeeds")
}
- if !got["vllm-remote"] {
- t.Fatalf("remote vllm model configured = false, want true with api_key")
+ if !gotAvailable["vllm-remote"] {
+ t.Fatalf("remote vllm model available = false, want true with api_key")
}
- if !got["copilot-gpt-5.4"] {
- t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds")
+ if !gotAvailable["copilot-gpt-5.4"] {
+ t.Fatalf("copilot model available = false, want true when local bridge probe succeeds")
+ }
+ if gotStatus["openai-oauth"] != modelStatusUnconfigured {
+ t.Fatalf("openai oauth model status = %q, want %q", gotStatus["openai-oauth"], modelStatusUnconfigured)
+ }
+ if gotStatus["vllm-local"] != modelStatusAvailable {
+ t.Fatalf("vllm local model status = %q, want %q", gotStatus["vllm-local"], modelStatusAvailable)
+ }
+ if gotStatus["ollama-default"] != modelStatusAvailable {
+ t.Fatalf("ollama default model status = %q, want %q", gotStatus["ollama-default"], modelStatusAvailable)
+ }
+ if gotStatus["vllm-remote"] != modelStatusAvailable {
+ t.Fatalf("remote vllm model status = %q, want %q", gotStatus["vllm-remote"], modelStatusAvailable)
+ }
+ if gotStatus["copilot-gpt-5.4"] != modelStatusAvailable {
+ t.Fatalf("copilot model status = %q, want %q", gotStatus["copilot-gpt-5.4"], modelStatusAvailable)
}
if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model|" {
t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes)
@@ -144,7 +161,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
}
}
-func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing.T) {
+func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
@@ -193,8 +210,8 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing
if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
}
- if !resp.Models[0].Configured {
- t.Fatalf("oauth model configured = false, want true with stored credential")
+ if !resp.Models[0].Available {
+ t.Fatalf("oauth model available = false, want true with stored credential")
}
}
@@ -306,14 +323,71 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {
if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
}
- if !resp.Models[0].Configured {
- t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization")
+ if !resp.Models[0].Available {
+ t.Fatal("wildcard-bound local model available = false, want true after probe host normalization")
}
if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" {
t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
}
}
+func TestHandleListModels_StatusMarksUnreachableLocalModel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+ resetOAuthHooks(t)
+ resetModelProbeHooks(t)
+
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ return false
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{{
+ ModelName: "vllm-local-down",
+ Model: "vllm/custom-model",
+ APIBase: "http://127.0.0.1:8000/v1",
+ APIKeys: config.SimpleSecureStrings("test-key"),
+ }}
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Models []modelResponse `json:"models"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Models) != 1 {
+ t.Fatalf("len(models) = %d, want 1", len(resp.Models))
+ }
+
+ if resp.Models[0].Available {
+ t.Fatal("unreachable local model available = true, want false")
+ }
+ if resp.Models[0].Status != modelStatusUnreachable {
+ t.Fatalf("unreachable local model status = %q, want %q", resp.Models[0].Status, modelStatusUnreachable)
+ }
+ if resp.Models[0].APIKey == "" {
+ t.Fatal("masked API key preview should still be returned when API key is configured")
+ }
+}
+
func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go
index 0e8cd07fc..c8ef47308 100644
--- a/web/backend/api/pico.go
+++ b/web/backend/api/pico.go
@@ -56,6 +56,7 @@ func (h *Handler) createWsProxy(origProtocol string, token string) *httputil.Rev
func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock()
+ ensurePicoTokenCachedLocked(h.configPath)
gatewayAvailable := gateway.pidData != nil
gateway.mu.Unlock()
diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go
index beff4d77f..ee5586746 100644
--- a/web/backend/api/pico_test.go
+++ b/web/backend/api/pico_test.go
@@ -377,6 +377,55 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
}
}
+func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ handler := h.handleWebSocketProxy()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/pico/ws" {
+ t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws")
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.WriteString(w, "proxied")
+ }))
+ defer server.Close()
+
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Host = "127.0.0.1"
+ cfg.Gateway.Port = mustGatewayTestPort(t, server.URL)
+ cfg.Channels.Pico.Enabled = true
+ cfg.Channels.Pico.SetToken("cached-token")
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ origPidData := gateway.pidData
+ origPicoToken := gateway.picoToken
+ t.Cleanup(func() {
+ gateway.pidData = origPidData
+ gateway.picoToken = origPicoToken
+ })
+
+ gateway.pidData = &ppid.PidFileData{}
+ gateway.picoToken = ""
+
+ req := httptest.NewRequest(http.MethodGet, "/pico/ws?session_id=test-session", nil)
+ req.Header.Set(protocolKey, tokenPrefix+"cached-token")
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+ if body := rec.Body.String(); body != "proxied" {
+ t.Fatalf("body = %q, want %q", body, "proxied")
+ }
+ if gateway.picoToken != "cached-token" {
+ t.Fatalf("gateway.picoToken = %q, want %q", gateway.picoToken, "cached-token")
+ }
+}
+
func mustGatewayTestPort(t *testing.T, rawURL string) int {
t.Helper()
diff --git a/web/backend/api/router.go b/web/backend/api/router.go
index af490d8b5..3823fe08c 100644
--- a/web/backend/api/router.go
+++ b/web/backend/api/router.go
@@ -14,6 +14,7 @@ type Handler struct {
serverPublic bool
serverPublicExplicit bool
serverCIDRs []string
+ debug bool
oauthMu sync.Mutex
oauthFlows map[string]*oauthFlow
oauthState map[string]string
@@ -43,6 +44,10 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
}
+func (h *Handler) SetDebug(debug bool) {
+ h.debug = debug
+}
+
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Config CRUD
diff --git a/web/backend/api/startup.go b/web/backend/api/startup.go
index 1c685bc90..8a3b8e8ff 100644
--- a/web/backend/api/startup.go
+++ b/web/backend/api/startup.go
@@ -90,6 +90,9 @@ func (h *Handler) resolveLaunchCommand() (string, []string, error) {
}
args := []string{"-no-browser"}
+ if h.debug {
+ args = append(args, "-d")
+ }
if h.configPath != "" {
args = append(args, h.configPath)
}
diff --git a/web/backend/api/startup_test.go b/web/backend/api/startup_test.go
index cfa9b4c53..c224d36e2 100644
--- a/web/backend/api/startup_test.go
+++ b/web/backend/api/startup_test.go
@@ -45,6 +45,29 @@ func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) {
}
}
+func TestResolveLaunchCommandIncludesDebugFlagWhenEnabled(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ h.SetDebug(true)
+
+ _, args, err := h.resolveLaunchCommand()
+ if err != nil {
+ t.Fatalf("resolveLaunchCommand() error = %v", err)
+ }
+ if len(args) != 3 {
+ t.Fatalf("args len = %d, want 3 (got %v)", len(args), args)
+ }
+ if args[0] != "-no-browser" {
+ t.Fatalf("args[0] = %q, want %q", args[0], "-no-browser")
+ }
+ if args[1] != "-d" {
+ t.Fatalf("args[1] = %q, want %q", args[1], "-d")
+ }
+ if args[2] != configPath {
+ t.Fatalf("args[2] = %q, want %q", args[2], configPath)
+ }
+}
+
func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) {
plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"})
if !strings.Contains(plist, "