Merge 62654a8cd8 into 412705783d
This commit is contained in:
commit
29fc13dcf7
22 changed files with 991 additions and 55 deletions
|
|
@ -16,7 +16,9 @@
|
||||||
"tool_feedback": {
|
"tool_feedback": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"max_args_length": 300,
|
"max_args_length": 300,
|
||||||
"separate_messages": false
|
"separate_messages": false,
|
||||||
|
"subagents": true,
|
||||||
|
"style": "raw"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,9 @@ Debug logs are server-side only. If you want the agent to send a visible notific
|
||||||
"tool_feedback": {
|
"tool_feedback": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_args_length": 300,
|
"max_args_length": 300,
|
||||||
"separate_messages": true
|
"separate_messages": true,
|
||||||
|
"subagents": true,
|
||||||
|
"style": "raw"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -80,6 +82,40 @@ When `enabled` is `true`, every tool call sends a short message to the chat befo
|
||||||
{"query": "picoclaw release notes"}
|
{"query": "picoclaw release notes"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Set `style` to `working_summary` to use a compact, non-argument progress message that can be edited in place as tools run:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"tool_feedback": {
|
||||||
|
"enabled": true,
|
||||||
|
"separate_messages": false,
|
||||||
|
"subagents": true,
|
||||||
|
"style": "working_summary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The message starts as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Working...
|
||||||
|
• tool: `read_file` — `README.md`
|
||||||
|
```
|
||||||
|
|
||||||
|
When more tools run, editable channels merge recent tool lines into the same progress message:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Working...
|
||||||
|
• tool: `read_file` — `README.md`
|
||||||
|
• tool: `exec` — `test.sh`
|
||||||
|
• tool: `write_file` — `config.json`
|
||||||
|
```
|
||||||
|
|
||||||
|
The `working_summary` style intentionally does not show raw tool arguments, explanations, URLs, command arguments, environment variables, or full paths. It only shows the tool name plus a safe basename for file tools and executable commands where available. This keeps progress visible without exposing secrets in chat history.
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
|
|
@ -87,15 +123,19 @@ When `enabled` is `true`, every tool call sends a short message to the chat befo
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `enabled` | bool | `false` | Send a chat notification for each tool call |
|
| `enabled` | bool | `false` | Send a chat notification for each tool call |
|
||||||
| `separate_messages` | bool | `false` | Keep every tool feedback update as a separate chat message instead of reusing a single placeholder/progress message |
|
| `separate_messages` | bool | `false` | Keep every tool feedback update as a separate chat message instead of reusing a single placeholder/progress message |
|
||||||
|
| `subagents` | bool | `true` | Also publish visible tool feedback for subagent turns when `enabled` is true |
|
||||||
| `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification |
|
| `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification |
|
||||||
|
| `style` | string | `raw` | Feedback format. Use `raw` for the original tool/explanation/argument preview, or `working_summary` for compact progress lines without raw arguments |
|
||||||
|
|
||||||
### Environment variables
|
### Environment variables
|
||||||
|
|
||||||
Both fields can also be set via environment variables:
|
These fields can also be set via environment variables:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED=true
|
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED=true
|
||||||
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH=300
|
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH=300
|
||||||
|
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES=false
|
||||||
|
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_STYLE=working_summary
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** `tool_feedback` is independent of `--debug` mode. It works in production and does not require the gateway to be started with any special flag.
|
> **Note:** `tool_feedback` is independent of `--debug` mode. It works in production and does not require the gateway to be started with any special flag.
|
||||||
|
|
|
||||||
|
|
@ -49,3 +49,12 @@ func (a *channelManagerAdapter) DismissToolFeedback(
|
||||||
) {
|
) {
|
||||||
a.inner.DismissToolFeedback(ctx, channel, chatID, outboundCtx)
|
a.inner.DismissToolFeedback(ctx, channel, chatID, outboundCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *channelManagerAdapter) DismissToolFeedbackForSession(
|
||||||
|
ctx context.Context,
|
||||||
|
channel, chatID string,
|
||||||
|
outboundCtx *bus.InboundContext,
|
||||||
|
sessionKey string,
|
||||||
|
) {
|
||||||
|
a.inner.DismissToolFeedbackForSession(ctx, channel, chatID, outboundCtx, sessionKey)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -261,7 +261,14 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if continued != "" {
|
if continued != "" {
|
||||||
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, continued)
|
al.publishResponseWithContextIfNeeded(
|
||||||
|
ctx,
|
||||||
|
target.Channel,
|
||||||
|
target.ChatID,
|
||||||
|
target.SessionKey,
|
||||||
|
continued,
|
||||||
|
&m.Context,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,14 @@ func (al *AgentLoop) publishResponseOrError(
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) {
|
func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) {
|
||||||
|
al.publishResponseWithContextIfNeeded(ctx, channel, chatID, sessionKey, response, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) publishResponseWithContextIfNeeded(
|
||||||
|
ctx context.Context,
|
||||||
|
channel, chatID, sessionKey, response string,
|
||||||
|
inboundCtx *bus.InboundContext,
|
||||||
|
) {
|
||||||
if response == "" {
|
if response == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -74,12 +82,21 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agent := al.agentForSession(sessionKey)
|
||||||
|
agentID := ""
|
||||||
|
if agent != nil {
|
||||||
|
agentID = agent.ID
|
||||||
|
}
|
||||||
msg := bus.OutboundMessage{
|
msg := bus.OutboundMessage{
|
||||||
Context: bus.NewOutboundContext(channel, chatID, ""),
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
|
Context: outboundContextFromInbound(inboundCtx, channel, chatID, ""),
|
||||||
|
AgentID: agentID,
|
||||||
|
SessionKey: sessionKey,
|
||||||
Content: response,
|
Content: response,
|
||||||
}
|
}
|
||||||
if sessionKey != "" {
|
if sessionKey != "" {
|
||||||
msg.ContextUsage = computeContextUsage(al.agentForSession(sessionKey), sessionKey)
|
msg.ContextUsage = computeContextUsage(agent, sessionKey)
|
||||||
}
|
}
|
||||||
al.bus.PublishOutbound(ctx, msg)
|
al.bus.PublishOutbound(ctx, msg)
|
||||||
logger.InfoCF("agent", "Published outbound response",
|
logger.InfoCF("agent", "Published outbound response",
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,14 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb
|
||||||
|
|
||||||
// Publish final response
|
// Publish final response
|
||||||
if finalResponse != "" {
|
if finalResponse != "" {
|
||||||
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse)
|
al.publishResponseWithContextIfNeeded(
|
||||||
|
ctx,
|
||||||
|
target.Channel,
|
||||||
|
target.ChatID,
|
||||||
|
target.SessionKey,
|
||||||
|
finalResponse,
|
||||||
|
&initialMsg.Context,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,45 @@ func newTestAgentLoop(
|
||||||
return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) }
|
return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPublishResponseWithContextIfNeeded_PreservesSessionMetadata(t *testing.T) {
|
||||||
|
al, _, msgBus, _, cleanup := newTestAgentLoop(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
inboundCtx := &bus.InboundContext{
|
||||||
|
Channel: "telegram",
|
||||||
|
ChatID: "-100123",
|
||||||
|
ChatType: "group",
|
||||||
|
TopicID: "6",
|
||||||
|
SenderID: "user-1",
|
||||||
|
}
|
||||||
|
al.publishResponseWithContextIfNeeded(
|
||||||
|
context.Background(),
|
||||||
|
"telegram",
|
||||||
|
"-100123",
|
||||||
|
"session-async-1",
|
||||||
|
"done",
|
||||||
|
inboundCtx,
|
||||||
|
)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case outbound := <-msgBus.OutboundChan():
|
||||||
|
if outbound.Channel != "telegram" || outbound.ChatID != "-100123" {
|
||||||
|
t.Fatalf("unexpected outbound target: channel=%q chat=%q", outbound.Channel, outbound.ChatID)
|
||||||
|
}
|
||||||
|
if outbound.Context.TopicID != "6" {
|
||||||
|
t.Fatalf("outbound topic_id = %q, want 6", outbound.Context.TopicID)
|
||||||
|
}
|
||||||
|
if outbound.AgentID != routing.DefaultAgentID {
|
||||||
|
t.Fatalf("outbound agent_id = %q, want %q", outbound.AgentID, routing.DefaultAgentID)
|
||||||
|
}
|
||||||
|
if outbound.SessionKey != "session-async-1" {
|
||||||
|
t.Fatalf("outbound session_key = %q, want session-async-1", outbound.SessionKey)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("expected outbound response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewAgentLoop_RegistersWebSearchTool(t *testing.T) {
|
func TestNewAgentLoop_RegistersWebSearchTool(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.Workspace = t.TempDir()
|
cfg.Agents.Defaults.Workspace = t.TempDir()
|
||||||
|
|
@ -2124,6 +2163,29 @@ func TestToolFeedbackArgsPreview_UsesJSONAndTruncates(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShouldPublishToolFeedback_DisablesSubagentFeedback(t *testing.T) {
|
||||||
|
subagents := false
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.ToolFeedback = config.ToolFeedbackConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Subagents: &subagents,
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldPublishToolFeedback(cfg, &turnState{
|
||||||
|
channel: "telegram",
|
||||||
|
sessionKey: "subturn-1",
|
||||||
|
}) {
|
||||||
|
t.Fatal("shouldPublishToolFeedback() = true for disabled subagent feedback, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shouldPublishToolFeedback(cfg, &turnState{
|
||||||
|
channel: "telegram",
|
||||||
|
sessionKey: "chat-1",
|
||||||
|
}) {
|
||||||
|
t.Fatal("shouldPublishToolFeedback() = false for main turn, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type picoInterleavedContentProvider struct {
|
type picoInterleavedContentProvider struct {
|
||||||
calls int
|
calls int
|
||||||
}
|
}
|
||||||
|
|
@ -4043,6 +4105,7 @@ func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
|
||||||
ToolFeedback: config.ToolFeedbackConfig{
|
ToolFeedback: config.ToolFeedbackConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
MaxArgsLength: 300,
|
MaxArgsLength: 300,
|
||||||
|
Style: utils.ToolFeedbackStyleWorkingSummary,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -4089,6 +4152,7 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
|
||||||
ToolFeedback: config.ToolFeedbackConfig{
|
ToolFeedback: config.ToolFeedbackConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
MaxArgsLength: 300,
|
MaxArgsLength: 300,
|
||||||
|
Style: utils.ToolFeedbackStyleWorkingSummary,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -4118,7 +4182,6 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case outbound := <-msgBus.OutboundChan():
|
case outbound := <-msgBus.OutboundChan():
|
||||||
escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`)
|
|
||||||
if outbound.Channel != "telegram" {
|
if outbound.Channel != "telegram" {
|
||||||
t.Fatalf("tool feedback channel = %q, want %q", outbound.Channel, "telegram")
|
t.Fatalf("tool feedback channel = %q, want %q", outbound.Channel, "telegram")
|
||||||
}
|
}
|
||||||
|
|
@ -4128,20 +4191,13 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
|
||||||
if outbound.Context.Channel != "telegram" || outbound.Context.ChatID != "chat-1" {
|
if outbound.Context.Channel != "telegram" || outbound.Context.ChatID != "chat-1" {
|
||||||
t.Fatalf("unexpected tool feedback context: %+v", outbound.Context)
|
t.Fatalf("unexpected tool feedback context: %+v", outbound.Context)
|
||||||
}
|
}
|
||||||
if !strings.Contains(outbound.Content, "`read_file`") {
|
if !strings.Contains(outbound.Content, "tool: `read_file`") {
|
||||||
t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content)
|
t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content)
|
||||||
}
|
}
|
||||||
if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) {
|
if strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) ||
|
||||||
t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content)
|
strings.Contains(outbound.Content, "check tool feedback") ||
|
||||||
}
|
strings.Contains(outbound.Content, "\"path\":") {
|
||||||
if !strings.Contains(outbound.Content, "check tool feedback") {
|
t.Fatalf("tool feedback content = %q, should only include compact tool names", outbound.Content)
|
||||||
t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content)
|
|
||||||
}
|
|
||||||
if !strings.Contains(outbound.Content, "\"path\":") {
|
|
||||||
t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content)
|
|
||||||
}
|
|
||||||
if !strings.Contains(outbound.Content, escapedHeartbeatFile) {
|
|
||||||
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
|
|
||||||
}
|
}
|
||||||
if strings.Contains(outbound.Content, "Previous turn explanation") {
|
if strings.Contains(outbound.Content, "Previous turn explanation") {
|
||||||
t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content)
|
t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content)
|
||||||
|
|
@ -4353,6 +4409,7 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T)
|
||||||
ToolFeedback: config.ToolFeedbackConfig{
|
ToolFeedback: config.ToolFeedbackConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
MaxArgsLength: 300,
|
MaxArgsLength: 300,
|
||||||
|
Style: utils.ToolFeedbackStyleWorkingSummary,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -4383,20 +4440,14 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T)
|
||||||
select {
|
select {
|
||||||
case outbound := <-msgBus.OutboundChan():
|
case outbound := <-msgBus.OutboundChan():
|
||||||
escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`)
|
escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`)
|
||||||
if !strings.Contains(outbound.Content, "`read_file`") {
|
if !strings.Contains(outbound.Content, "tool: `read_file`") {
|
||||||
t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content)
|
t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content)
|
||||||
}
|
}
|
||||||
if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) {
|
if strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) ||
|
||||||
t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content)
|
strings.Contains(outbound.Content, "check reasoning fallback") ||
|
||||||
}
|
strings.Contains(outbound.Content, "\"path\":") ||
|
||||||
if !strings.Contains(outbound.Content, "check reasoning fallback") {
|
strings.Contains(outbound.Content, escapedHeartbeatFile) {
|
||||||
t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content)
|
t.Fatalf("tool feedback content = %q, should only include compact tool names", outbound.Content)
|
||||||
}
|
|
||||||
if !strings.Contains(outbound.Content, "\"path\":") {
|
|
||||||
t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content)
|
|
||||||
}
|
|
||||||
if !strings.Contains(outbound.Content, escapedHeartbeatFile) {
|
|
||||||
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
|
|
||||||
}
|
}
|
||||||
if strings.Contains(outbound.Content, "Read README.md first") {
|
if strings.Contains(outbound.Content, "Read README.md first") {
|
||||||
t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content)
|
t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content)
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,52 @@ func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool {
|
||||||
if ts == nil || ts.channel == "" || ts.opts.SuppressToolFeedback {
|
if ts == nil || ts.channel == "" || ts.opts.SuppressToolFeedback {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return cfg != nil && cfg.Agents.Defaults.IsToolFeedbackEnabled()
|
if cfg == nil || !cfg.Agents.Defaults.IsToolFeedbackEnabled() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(ts.sessionKey), "subturn-") &&
|
||||||
|
!cfg.Agents.Defaults.IsSubagentToolFeedbackEnabled() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolFeedbackTitleForTurn(ts *turnState) string {
|
||||||
|
if ts == nil || !strings.HasPrefix(strings.TrimSpace(ts.sessionKey), "subturn-") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if ts.agent == nil {
|
||||||
|
return "Subagent"
|
||||||
|
}
|
||||||
|
if title := displayAgentName(ts.agent.ID); title != "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
return "Subagent"
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayAgentName(agentID string) string {
|
||||||
|
agentID = strings.TrimSpace(agentID)
|
||||||
|
if agentID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.FieldsFunc(agentID, func(r rune) bool {
|
||||||
|
return r == '-' || r == '_' || r == ' '
|
||||||
|
})
|
||||||
|
var b strings.Builder
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Len() > 0 {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
b.WriteString(strings.ToUpper(part[:1]))
|
||||||
|
if len(part) > 1 {
|
||||||
|
b.WriteString(part[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneEventArguments(args map[string]any) map[string]any {
|
func cloneEventArguments(args map[string]any) map[string]any {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/session"
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newHookTestLoop(
|
func newHookTestLoop(
|
||||||
|
|
@ -809,6 +810,7 @@ func TestAgentLoop_Hooks_ToolFeedbackUsesRewrittenToolName(t *testing.T) {
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
al.cfg.Agents.Defaults.ToolFeedback.Enabled = true
|
al.cfg.Agents.Defaults.ToolFeedback.Enabled = true
|
||||||
|
al.cfg.Agents.Defaults.ToolFeedback.Style = utils.ToolFeedbackStyleWorkingSummary
|
||||||
al.RegisterTool(&echoTextTool{})
|
al.RegisterTool(&echoTextTool{})
|
||||||
al.RegisterTool(&echoTextRewrittenTool{})
|
al.RegisterTool(&echoTextRewrittenTool{})
|
||||||
if err := al.MountHook(NamedHook("tool-rename", &toolRenameHook{})); err != nil {
|
if err := al.MountHook(NamedHook("tool-rename", &toolRenameHook{})); err != nil {
|
||||||
|
|
@ -835,10 +837,10 @@ func TestAgentLoop_Hooks_ToolFeedbackUsesRewrittenToolName(t *testing.T) {
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case outbound := <-msgBus.OutboundChan():
|
case outbound := <-msgBus.OutboundChan():
|
||||||
if !strings.Contains(outbound.Content, "`echo_text_rewritten`") {
|
if !strings.Contains(outbound.Content, "tool: `echo_text_rewritten`") {
|
||||||
t.Fatalf("tool feedback content = %q, want rewritten tool name", outbound.Content)
|
t.Fatalf("tool feedback content = %q, want rewritten tool name", outbound.Content)
|
||||||
}
|
}
|
||||||
if strings.Contains(outbound.Content, "`echo_text`") {
|
if strings.Contains(outbound.Content, "tool: `echo_text`\n") {
|
||||||
t.Fatalf("tool feedback content = %q, want no original tool name", outbound.Content)
|
t.Fatalf("tool feedback content = %q, want no original tool name", outbound.Content)
|
||||||
}
|
}
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
|
|
|
||||||
|
|
@ -51,4 +51,16 @@ type ChannelManager interface {
|
||||||
// outboundCtx carries topic/thread info needed for channels that use
|
// outboundCtx carries topic/thread info needed for channels that use
|
||||||
// scoped tracker keys (e.g., Telegram forum topics); may be nil.
|
// scoped tracker keys (e.g., Telegram forum topics); may be nil.
|
||||||
DismissToolFeedback(ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext)
|
DismissToolFeedback(ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext)
|
||||||
|
|
||||||
|
// DismissToolFeedbackForSession clears a session-scoped tool feedback
|
||||||
|
// message. This is used for background sub-turns whose progress messages
|
||||||
|
// are visible in the originating chat, but whose final result is delivered
|
||||||
|
// asynchronously through the parent turn instead of as a direct channel
|
||||||
|
// response.
|
||||||
|
DismissToolFeedbackForSession(
|
||||||
|
ctx context.Context,
|
||||||
|
channel, chatID string,
|
||||||
|
outboundCtx *bus.InboundContext,
|
||||||
|
sessionKey string,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -174,11 +174,21 @@ toolLoop:
|
||||||
tc,
|
tc,
|
||||||
messages,
|
messages,
|
||||||
)
|
)
|
||||||
feedbackMsg := utils.FormatToolFeedbackMessage(
|
feedbackMsg := utils.FormatToolFeedbackMessageWithStyle(
|
||||||
|
al.cfg.Agents.Defaults.GetToolFeedbackStyle(),
|
||||||
toolName,
|
toolName,
|
||||||
toolFeedbackExplanation,
|
toolFeedbackExplanation,
|
||||||
toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen),
|
toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen),
|
||||||
)
|
)
|
||||||
|
if title := toolFeedbackTitleForTurn(ts); title != "" {
|
||||||
|
feedbackMsg = utils.FormatToolFeedbackMessageWithStyleAndTitle(
|
||||||
|
al.cfg.Agents.Defaults.GetToolFeedbackStyle(),
|
||||||
|
title,
|
||||||
|
toolName,
|
||||||
|
toolFeedbackExplanation,
|
||||||
|
toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen),
|
||||||
|
)
|
||||||
|
}
|
||||||
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
||||||
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback))
|
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback))
|
||||||
fbCancel()
|
fbCancel()
|
||||||
|
|
@ -461,11 +471,21 @@ toolLoop:
|
||||||
tc,
|
tc,
|
||||||
messages,
|
messages,
|
||||||
)
|
)
|
||||||
feedbackMsg := utils.FormatToolFeedbackMessage(
|
feedbackMsg := utils.FormatToolFeedbackMessageWithStyle(
|
||||||
|
al.cfg.Agents.Defaults.GetToolFeedbackStyle(),
|
||||||
toolName,
|
toolName,
|
||||||
toolFeedbackExplanation,
|
toolFeedbackExplanation,
|
||||||
toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen),
|
toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen),
|
||||||
)
|
)
|
||||||
|
if title := toolFeedbackTitleForTurn(ts); title != "" {
|
||||||
|
feedbackMsg = utils.FormatToolFeedbackMessageWithStyleAndTitle(
|
||||||
|
al.cfg.Agents.Defaults.GetToolFeedbackStyle(),
|
||||||
|
title,
|
||||||
|
toolName,
|
||||||
|
toolFeedbackExplanation,
|
||||||
|
toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen),
|
||||||
|
)
|
||||||
|
}
|
||||||
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
||||||
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback))
|
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback))
|
||||||
fbCancel()
|
fbCancel()
|
||||||
|
|
|
||||||
|
|
@ -462,6 +462,17 @@ func spawnSubTurn(
|
||||||
|
|
||||||
// Result Delivery Strategy (Async vs Sync)
|
// Result Delivery Strategy (Async vs Sync)
|
||||||
if cfg.Async {
|
if cfg.Async {
|
||||||
|
if al != nil && al.channelManager != nil && childTS.channel != "" {
|
||||||
|
dismissCtx, dismissCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
al.channelManager.DismissToolFeedbackForSession(
|
||||||
|
dismissCtx,
|
||||||
|
childTS.channel,
|
||||||
|
childTS.chatID,
|
||||||
|
childTS.opts.Dispatch.InboundContext,
|
||||||
|
childID,
|
||||||
|
)
|
||||||
|
dismissCancel()
|
||||||
|
}
|
||||||
deliverSubTurnResult(al, parentTS, childID, result)
|
deliverSubTurnResult(al, parentTS, childID, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,15 @@ func trackedToolFeedbackMessageChatID(ch Channel, chatID string, outboundCtx *bu
|
||||||
return strings.TrimSpace(chatID)
|
return strings.TrimSpace(chatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func candidateToolFeedbackMessageChatIDs(raw, resolved string) []string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
resolved = strings.TrimSpace(resolved)
|
||||||
|
if raw == "" || raw == resolved {
|
||||||
|
return []string{resolved}
|
||||||
|
}
|
||||||
|
return []string{resolved, raw}
|
||||||
|
}
|
||||||
|
|
||||||
func dismissTrackedToolFeedbackMessage(
|
func dismissTrackedToolFeedbackMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
ch Channel,
|
ch Channel,
|
||||||
|
|
@ -210,6 +219,38 @@ func dismissTrackedToolFeedbackMessage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dismissTrackedToolFeedbackMessageForSession(
|
||||||
|
ctx context.Context,
|
||||||
|
ch Channel,
|
||||||
|
chatID string,
|
||||||
|
outboundCtx *bus.InboundContext,
|
||||||
|
sessionKey string,
|
||||||
|
) {
|
||||||
|
sessionKey = strings.TrimSpace(sessionKey)
|
||||||
|
if sessionKey == "" {
|
||||||
|
dismissTrackedToolFeedbackMessage(ctx, ch, chatID, outboundCtx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolvedChatID := trackedToolFeedbackMessageChatID(ch, chatID, outboundCtx)
|
||||||
|
if cleaner, ok := ch.(toolFeedbackMessageCleaner); ok {
|
||||||
|
for _, candidate := range candidateToolFeedbackMessageChatIDs(chatID, resolvedChatID) {
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaner.DismissToolFeedbackMessage(ctx, candidate+"#session:"+sessionKey)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tracker, ok := ch.(toolFeedbackMessageTracker); ok {
|
||||||
|
for _, candidate := range candidateToolFeedbackMessageChatIDs(chatID, resolvedChatID) {
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tracker.ClearToolFeedbackMessage(candidate + "#session:" + sessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func clearTrackedToolFeedbackMessage(
|
func clearTrackedToolFeedbackMessage(
|
||||||
ch Channel,
|
ch Channel,
|
||||||
chatID string,
|
chatID string,
|
||||||
|
|
@ -239,6 +280,16 @@ func (m *Manager) DismissToolFeedback(
|
||||||
dismissTrackedToolFeedbackMessage(ctx, ch, chatID, outboundCtx)
|
dismissTrackedToolFeedbackMessage(ctx, ch, chatID, outboundCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) DismissToolFeedbackForSession(
|
||||||
|
ctx context.Context, channelName, chatID string, outboundCtx *bus.InboundContext, sessionKey string,
|
||||||
|
) {
|
||||||
|
ch, ok := m.GetChannel(channelName)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dismissTrackedToolFeedbackMessageForSession(ctx, ch, chatID, outboundCtx, sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
func prepareToolFeedbackMessageContent(ch Channel, content string) string {
|
func prepareToolFeedbackMessageContent(ch Channel, content string) string {
|
||||||
prepared := strings.TrimSpace(content)
|
prepared := strings.TrimSpace(content)
|
||||||
if prepared == "" {
|
if prepared == "" {
|
||||||
|
|
|
||||||
|
|
@ -919,6 +919,7 @@ type mockMessageEditor struct {
|
||||||
recordedContent string
|
recordedContent string
|
||||||
clearedChatID string
|
clearedChatID string
|
||||||
dismissedChatID string
|
dismissedChatID string
|
||||||
|
dismissedChatIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error {
|
func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error {
|
||||||
|
|
@ -937,6 +938,7 @@ func (m *mockMessageEditor) ClearToolFeedbackMessage(chatID string) {
|
||||||
|
|
||||||
func (m *mockMessageEditor) DismissToolFeedbackMessage(_ context.Context, chatID string) {
|
func (m *mockMessageEditor) DismissToolFeedbackMessage(_ context.Context, chatID string) {
|
||||||
m.dismissedChatID = chatID
|
m.dismissedChatID = chatID
|
||||||
|
m.dismissedChatIDs = append(m.dismissedChatIDs, chatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMessageEditor) FinalizeToolFeedbackMessage(
|
func (m *mockMessageEditor) FinalizeToolFeedbackMessage(
|
||||||
|
|
@ -979,6 +981,34 @@ func (m *mockResolvedToolFeedbackEditor) ToolFeedbackMessageChatID(
|
||||||
return chatID
|
return chatID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDismissToolFeedbackForSession_UsesResolvedTopicScopedKey(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
ch := &mockResolvedToolFeedbackEditor{
|
||||||
|
resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string {
|
||||||
|
if chatID != "-100123" {
|
||||||
|
t.Fatalf("chatID = %q, want -100123", chatID)
|
||||||
|
}
|
||||||
|
if outboundCtx == nil || outboundCtx.TopicID != "6" {
|
||||||
|
t.Fatalf("unexpected outbound context: %+v", outboundCtx)
|
||||||
|
}
|
||||||
|
return "-100123/6"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
m.channels["telegram"] = ch
|
||||||
|
|
||||||
|
m.DismissToolFeedbackForSession(
|
||||||
|
context.Background(),
|
||||||
|
"telegram",
|
||||||
|
"-100123",
|
||||||
|
&bus.InboundContext{Channel: "telegram", ChatID: "-100123", TopicID: "6"},
|
||||||
|
"subturn-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(ch.dismissedChatIDs) == 0 || ch.dismissedChatIDs[0] != "-100123/6#session:subturn-1" {
|
||||||
|
t.Fatalf("dismissed chatIDs = %v, want topic-scoped session key first", ch.dismissedChatIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type mockPreparedToolFeedbackEditor struct {
|
type mockPreparedToolFeedbackEditor struct {
|
||||||
mockMessageEditor
|
mockMessageEditor
|
||||||
prepareFn func(content string) string
|
prepareFn func(content string) string
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]
|
||||||
if isToolFeedback {
|
if isToolFeedback {
|
||||||
toolFeedbackContent = fitToolFeedbackForTelegram(msg.Content, useMarkdownV2, 4096)
|
toolFeedbackContent = fitToolFeedbackForTelegram(msg.Content, useMarkdownV2, 4096)
|
||||||
}
|
}
|
||||||
trackedChatID := telegramToolFeedbackChatKey(msg.ChatID, &msg.Context)
|
trackedChatID := telegramToolFeedbackTrackerKey(msg)
|
||||||
if isToolFeedback {
|
if isToolFeedback {
|
||||||
if msgID, handled, err := c.progress.Update(ctx, trackedChatID, toolFeedbackContent); handled {
|
if msgID, handled, err := c.progress.Update(ctx, trackedChatID, toolFeedbackContent); handled {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -441,6 +441,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
|
||||||
// EditMessage implements channels.MessageEditor.
|
// EditMessage implements channels.MessageEditor.
|
||||||
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||||
useMarkdownV2 := c.tgCfg.UseMarkdownV2
|
useMarkdownV2 := c.tgCfg.UseMarkdownV2
|
||||||
|
chatID = telegramToolFeedbackDeliveryChatID(chatID)
|
||||||
cid, _, err := parseTelegramChatID(chatID)
|
cid, _, err := parseTelegramChatID(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -497,6 +498,7 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag
|
||||||
|
|
||||||
// DeleteMessage implements channels.MessageDeleter.
|
// DeleteMessage implements channels.MessageDeleter.
|
||||||
func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error {
|
func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error {
|
||||||
|
chatID = telegramToolFeedbackDeliveryChatID(chatID)
|
||||||
cid, _, err := parseTelegramChatID(chatID)
|
cid, _, err := parseTelegramChatID(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -518,6 +520,23 @@ func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
|
||||||
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func telegramToolFeedbackTrackerKey(msg bus.OutboundMessage) string {
|
||||||
|
key := telegramToolFeedbackChatKey(msg.ChatID, &msg.Context)
|
||||||
|
sessionKey := strings.TrimSpace(msg.SessionKey)
|
||||||
|
if key == "" || sessionKey == "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return key + "#session:" + sessionKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func telegramToolFeedbackDeliveryChatID(chatID string) string {
|
||||||
|
chatID = strings.TrimSpace(chatID)
|
||||||
|
if idx := strings.Index(chatID, "#session:"); idx >= 0 {
|
||||||
|
return strings.TrimSpace(chatID[:idx])
|
||||||
|
}
|
||||||
|
return chatID
|
||||||
|
}
|
||||||
|
|
||||||
func (c *TelegramChannel) currentToolFeedbackMessage(chatID string) (string, bool) {
|
func (c *TelegramChannel) currentToolFeedbackMessage(chatID string) (string, bool) {
|
||||||
if c.progress == nil {
|
if c.progress == nil {
|
||||||
return "", false
|
return "", false
|
||||||
|
|
@ -559,7 +578,7 @@ func (c *TelegramChannel) dismissTrackedToolFeedbackMessage(ctx context.Context,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.ClearToolFeedbackMessage(chatID)
|
c.ClearToolFeedbackMessage(chatID)
|
||||||
_ = c.DeleteMessage(ctx, chatID, messageID)
|
_ = c.DeleteMessage(ctx, telegramToolFeedbackDeliveryChatID(chatID), messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramChannel) finalizeTrackedToolFeedbackMessage(
|
func (c *TelegramChannel) finalizeTrackedToolFeedbackMessage(
|
||||||
|
|
@ -572,7 +591,7 @@ func (c *TelegramChannel) finalizeTrackedToolFeedbackMessage(
|
||||||
if !ok || editFn == nil {
|
if !ok || editFn == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
if err := editFn(ctx, chatID, msgID, content); err != nil {
|
if err := editFn(ctx, telegramToolFeedbackDeliveryChatID(chatID), msgID, content); err != nil {
|
||||||
c.RecordToolFeedbackMessage(chatID, msgID, baseContent)
|
c.RecordToolFeedbackMessage(chatID, msgID, baseContent)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,115 @@ func TestSend_TopicReplyDoesNotFinalizeDifferentTopicToolFeedback(t *testing.T)
|
||||||
assert.True(t, ok, "tool feedback in the original topic should remain tracked")
|
assert.True(t, ok, "tool feedback in the original topic should remain tracked")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSend_FinalReplyDoesNotFinalizeDifferentSessionToolFeedback(t *testing.T) {
|
||||||
|
nextMessageID := 0
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
nextMessageID++
|
||||||
|
return successResponseWithMessageID(t, nextMessageID), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
||||||
|
|
||||||
|
baseCtx := bus.InboundContext{
|
||||||
|
Channel: "telegram",
|
||||||
|
ChatID: "-1001234567890",
|
||||||
|
TopicID: "42",
|
||||||
|
}
|
||||||
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "-1001234567890",
|
||||||
|
SessionKey: "subturn-1",
|
||||||
|
Content: "Working...\n• tool: `read_file`",
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "telegram",
|
||||||
|
ChatID: "-1001234567890",
|
||||||
|
TopicID: "42",
|
||||||
|
Raw: map[string]string{
|
||||||
|
"message_kind": "tool_feedback",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ids, err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "-1001234567890",
|
||||||
|
SessionKey: "main-session",
|
||||||
|
Content: "test",
|
||||||
|
Context: baseCtx,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, caller.calls, 2)
|
||||||
|
assert.Equal(t, []string{"2"}, ids)
|
||||||
|
assert.Contains(t, caller.calls[1].URL, "sendMessage")
|
||||||
|
assert.NotContains(t, caller.calls[1].URL, "editMessageText")
|
||||||
|
|
||||||
|
msgID, ok := ch.currentToolFeedbackMessage("-1001234567890/42#session:subturn-1")
|
||||||
|
require.True(t, ok, "subturn tool feedback should remain tracked")
|
||||||
|
assert.Equal(t, "1", msgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_SessionScopedToolFeedbackUpdatesExistingTelegramMessage(t *testing.T) {
|
||||||
|
nextMessageID := 0
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
nextMessageID++
|
||||||
|
return successResponseWithMessageID(t, nextMessageID), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
||||||
|
|
||||||
|
baseCtx := bus.InboundContext{
|
||||||
|
Channel: "telegram",
|
||||||
|
ChatID: "-1001234567890",
|
||||||
|
TopicID: "42",
|
||||||
|
Raw: map[string]string{
|
||||||
|
"message_kind": "tool_feedback",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "-1001234567890",
|
||||||
|
SessionKey: "subturn-1",
|
||||||
|
Content: "Working...\n• tool: `read_file`",
|
||||||
|
Context: baseCtx,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ids, err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "-1001234567890",
|
||||||
|
SessionKey: "subturn-1",
|
||||||
|
Content: "Working...\n• tool: `read_file`\n• tool: `mcp_gpt_researcher_deep_research`",
|
||||||
|
Context: baseCtx,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"1"}, ids)
|
||||||
|
require.Len(t, caller.calls, 2)
|
||||||
|
assert.Contains(t, caller.calls[1].URL, "editMessageText")
|
||||||
|
assert.NotContains(t, caller.calls[1].URL, "%23session")
|
||||||
|
assert.NotContains(t, caller.calls[1].URL, "#session")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDismissToolFeedbackMessage_SessionScopedKeyDeletesTelegramMessage(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
return successResponse(t), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
ch.RecordToolFeedbackMessage("-1001234567890/42#session:subturn-1", "7", "Working...\n• tool: `read_file`")
|
||||||
|
|
||||||
|
ch.DismissToolFeedbackMessage(context.Background(), "-1001234567890/42#session:subturn-1")
|
||||||
|
|
||||||
|
require.Len(t, caller.calls, 1)
|
||||||
|
assert.Contains(t, caller.calls[0].URL, "deleteMessage")
|
||||||
|
assert.NotContains(t, caller.calls[0].URL, "%23session")
|
||||||
|
assert.NotContains(t, caller.calls[0].URL, "#session")
|
||||||
|
_, ok := ch.currentToolFeedbackMessage("-1001234567890/42#session:subturn-1")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) {
|
func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) {
|
||||||
ch := newTestChannel(t, &stubCaller{
|
ch := newTestChannel(t, &stubCaller{
|
||||||
callFn: func(context.Context, string, *ta.RequestData) (*ta.Response, error) {
|
callFn: func(context.Context, string, *ta.RequestData) (*ta.Response, error) {
|
||||||
|
|
@ -442,6 +551,20 @@ func TestFitToolFeedbackForTelegram_ReservesAnimationFrame(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseContent_WorkingSummaryToolNamesStayCode(t *testing.T) {
|
||||||
|
content := "Working...\n• tool: `inventorydb__get_location`"
|
||||||
|
|
||||||
|
htmlContent := parseContent(content, false)
|
||||||
|
if !strings.Contains(htmlContent, "<code>inventorydb__get_location</code>") {
|
||||||
|
t.Fatalf("parseContent() HTML = %q, want tool name code span", htmlContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
markdownV2Content := parseContent(content, true)
|
||||||
|
if !strings.Contains(markdownV2Content, "`inventorydb__get_location`") {
|
||||||
|
t.Fatalf("parseContent() MarkdownV2 = %q, want tool name code span", markdownV2Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSend_LongMessage_SingleCall(t *testing.T) {
|
func TestSend_LongMessage_SingleCall(t *testing.T) {
|
||||||
// With WithMaxMessageLength(4000), the Manager pre-splits messages before
|
// With WithMaxMessageLength(4000), the Manager pre-splits messages before
|
||||||
// they reach Send(). A message at exactly 4000 chars should go through
|
// they reach Send(). A message at exactly 4000 chars should go through
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const toolFeedbackAnimationInterval = 3 * time.Second
|
const toolFeedbackAnimationInterval = 3 * time.Second
|
||||||
|
const workingSummaryToolFeedbackAnimationInterval = 10 * time.Second
|
||||||
|
const maxMergedToolFeedbackLines = 8
|
||||||
|
|
||||||
const initialToolFeedbackAnimationFrame = ""
|
const initialToolFeedbackAnimationFrame = ""
|
||||||
|
|
||||||
|
|
@ -122,13 +124,17 @@ func (a *ToolFeedbackAnimator) Update(ctx context.Context, chatID, content strin
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
animatedContent := InitialAnimatedToolFeedbackContent(content)
|
mergedContent := content
|
||||||
|
if isWorkingSummaryToolFeedback(baseContent) || isWorkingSummaryToolFeedback(content) {
|
||||||
|
mergedContent = mergeToolFeedbackContent(baseContent, content)
|
||||||
|
}
|
||||||
|
animatedContent := InitialAnimatedToolFeedbackContent(mergedContent)
|
||||||
if err := a.editFn(ctx, strings.TrimSpace(chatID), msgID, animatedContent); err != nil {
|
if err := a.editFn(ctx, strings.TrimSpace(chatID), msgID, animatedContent); err != nil {
|
||||||
a.Record(chatID, msgID, baseContent)
|
a.Record(chatID, msgID, baseContent)
|
||||||
return "", true, err
|
return "", true, err
|
||||||
}
|
}
|
||||||
|
|
||||||
a.Record(chatID, msgID, content)
|
a.Record(chatID, msgID, mergedContent)
|
||||||
return msgID, true, nil
|
return msgID, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,7 +169,7 @@ func (a *ToolFeedbackAnimator) detach(chatID string) *toolFeedbackAnimationState
|
||||||
func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationState) {
|
func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationState) {
|
||||||
defer close(entry.done)
|
defer close(entry.done)
|
||||||
|
|
||||||
ticker := time.NewTicker(toolFeedbackAnimationInterval)
|
ticker := time.NewTicker(toolFeedbackAnimationIntervalFor(entry.baseContent))
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
frameIdx := 1
|
frameIdx := 1
|
||||||
|
|
@ -227,6 +233,77 @@ func appendToolFeedbackFrame(firstLine, frame string) string {
|
||||||
return firstLine + frame
|
return firstLine + frame
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mergeToolFeedbackContent(previous, next string) string {
|
||||||
|
previous = strings.TrimSpace(previous)
|
||||||
|
next = strings.TrimSpace(next)
|
||||||
|
if previous == "" {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
if next == "" {
|
||||||
|
return previous
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make([]string, 0, maxMergedToolFeedbackLines+1)
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
addLine := func(line string) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || isWorkingSummaryHeader(line) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[line]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[line] = struct{}{}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(previous, "\n") {
|
||||||
|
addLine(line)
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(next, "\n") {
|
||||||
|
addLine(line)
|
||||||
|
}
|
||||||
|
if len(lines) > maxMergedToolFeedbackLines {
|
||||||
|
lines = lines[len(lines)-maxMergedToolFeedbackLines:]
|
||||||
|
}
|
||||||
|
header := workingSummaryHeader(previous)
|
||||||
|
if candidate := workingSummaryHeader(next); candidate != "" {
|
||||||
|
header = candidate
|
||||||
|
}
|
||||||
|
if header == "" {
|
||||||
|
header = "Working..."
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
return header + "\n" + strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWorkingSummaryToolFeedback(content string) bool {
|
||||||
|
return isWorkingSummaryHeader(workingSummaryHeader(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func workingSummaryHeader(content string) string {
|
||||||
|
firstLine, _, _ := strings.Cut(strings.TrimSpace(content), "\n")
|
||||||
|
firstLine = strings.TrimSpace(firstLine)
|
||||||
|
if isWorkingSummaryHeader(firstLine) {
|
||||||
|
return firstLine
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWorkingSummaryHeader(line string) bool {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
return strings.EqualFold(line, "Working...") || strings.HasSuffix(strings.ToLower(line), " working...")
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolFeedbackAnimationIntervalFor(content string) time.Duration {
|
||||||
|
if isWorkingSummaryToolFeedback(content) {
|
||||||
|
return workingSummaryToolFeedbackAnimationInterval
|
||||||
|
}
|
||||||
|
return toolFeedbackAnimationInterval
|
||||||
|
}
|
||||||
|
|
||||||
func stopToolFeedbackAnimation(entry *toolFeedbackAnimationState) {
|
func stopToolFeedbackAnimation(entry *toolFeedbackAnimationState) {
|
||||||
if entry == nil {
|
if entry == nil {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -75,16 +75,17 @@ func TestToolFeedbackAnimator_UpdateStopsTrackingBeforeEdit(t *testing.T) {
|
||||||
if messageID != "msg-1" {
|
if messageID != "msg-1" {
|
||||||
t.Fatalf("messageID = %q, want msg-1", messageID)
|
t.Fatalf("messageID = %q, want msg-1", messageID)
|
||||||
}
|
}
|
||||||
if content != "🔧 `write_file`\nUpdating config" {
|
want := "Working...\n• tool: `read_file`\n• tool: `write_file`"
|
||||||
|
if content != want {
|
||||||
t.Fatalf("content = %q, want updated animated content", content)
|
t.Fatalf("content = %q, want updated animated content", content)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
defer animator.StopAll()
|
defer animator.StopAll()
|
||||||
|
|
||||||
animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config")
|
animator.Record("chat-1", "msg-1", "Working...\n• tool: `read_file`")
|
||||||
|
|
||||||
msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config")
|
msgID, handled, err := animator.Update(context.Background(), "chat-1", "Working...\n• tool: `write_file`")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Update() error = %v", err)
|
t.Fatalf("Update() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -96,6 +97,51 @@ func TestToolFeedbackAnimator_UpdateStopsTrackingBeforeEdit(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToolFeedbackAnimator_UpdateRawFeedbackReplacesContent(t *testing.T) {
|
||||||
|
var animator *ToolFeedbackAnimator
|
||||||
|
animator = NewToolFeedbackAnimator(func(_ context.Context, chatID, messageID, content string) error {
|
||||||
|
if _, ok := animator.Current(chatID); ok {
|
||||||
|
t.Fatal("expected tracked tool feedback to be stopped before edit")
|
||||||
|
}
|
||||||
|
if messageID != "msg-1" {
|
||||||
|
t.Fatalf("messageID = %q, want msg-1", messageID)
|
||||||
|
}
|
||||||
|
want := "🔧 `write_file`\nWriting config"
|
||||||
|
if content != want {
|
||||||
|
t.Fatalf("content = %q, want replacement content", content)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer animator.StopAll()
|
||||||
|
|
||||||
|
animator.Record("chat-1", "msg-1", "🔧 `read_file`\nReading config")
|
||||||
|
|
||||||
|
msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nWriting config")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Update() error = %v", err)
|
||||||
|
}
|
||||||
|
if !handled {
|
||||||
|
t.Fatal("Update() handled = false, want true")
|
||||||
|
}
|
||||||
|
if msgID != "msg-1" {
|
||||||
|
t.Fatalf("Update() msgID = %q, want msg-1", msgID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolFeedbackAnimationIntervalForWorkingSummary(t *testing.T) {
|
||||||
|
got := toolFeedbackAnimationIntervalFor("Working...\n• tool: `read_file`")
|
||||||
|
if got != workingSummaryToolFeedbackAnimationInterval {
|
||||||
|
t.Fatalf("toolFeedbackAnimationIntervalFor() = %v, want %v", got, workingSummaryToolFeedbackAnimationInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolFeedbackAnimationIntervalForRawFeedback(t *testing.T) {
|
||||||
|
got := toolFeedbackAnimationIntervalFor("🔧 `read_file`\nReading config")
|
||||||
|
if got != toolFeedbackAnimationInterval {
|
||||||
|
t.Fatalf("toolFeedbackAnimationIntervalFor() = %v, want %v", got, toolFeedbackAnimationInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) {
|
func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) {
|
||||||
editErr := errors.New("edit failed")
|
editErr := errors.New("edit failed")
|
||||||
animator := NewToolFeedbackAnimator(func(context.Context, string, string, string) error {
|
animator := NewToolFeedbackAnimator(func(context.Context, string, string, string) error {
|
||||||
|
|
@ -103,9 +149,9 @@ func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) {
|
||||||
})
|
})
|
||||||
defer animator.StopAll()
|
defer animator.StopAll()
|
||||||
|
|
||||||
animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config")
|
animator.Record("chat-1", "msg-1", "Working...\n• tool: `read_file`")
|
||||||
|
|
||||||
msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config")
|
msgID, handled, err := animator.Update(context.Background(), "chat-1", "Working...\n• tool: `write_file`")
|
||||||
if !handled {
|
if !handled {
|
||||||
t.Fatal("Update() handled = false, want true")
|
t.Fatal("Update() handled = false, want true")
|
||||||
}
|
}
|
||||||
|
|
@ -119,3 +165,20 @@ func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) {
|
||||||
t.Fatalf("Current() after failed Update = (%q, %v), want (msg-1, true)", currentID, ok)
|
t.Fatalf("Current() after failed Update = (%q, %v), want (msg-1, true)", currentID, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeToolFeedbackContent_PreservesNamedWorkingSummaryHeader(t *testing.T) {
|
||||||
|
got := mergeToolFeedbackContent(
|
||||||
|
"Deep Research working...\n• tool: `read_file`",
|
||||||
|
"Deep Research working...\n• tool: `web_fetch`",
|
||||||
|
)
|
||||||
|
want := "Deep Research working...\n• tool: `read_file`\n• tool: `web_fetch`"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("mergeToolFeedbackContent() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsWorkingSummaryToolFeedback_AcceptsNamedHeader(t *testing.T) {
|
||||||
|
if !isWorkingSummaryToolFeedback("Deep Research working...\n• tool: `read_file`") {
|
||||||
|
t.Fatal("expected named working summary to be recognized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,8 @@ type ToolFeedbackConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"`
|
||||||
MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"`
|
MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"`
|
||||||
SeparateMessages bool `json:"separate_messages" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES"`
|
SeparateMessages bool `json:"separate_messages" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES"`
|
||||||
|
Subagents *bool `json:"subagents,omitempty"`
|
||||||
|
Style string `json:"style,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_STYLE"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentDefaults struct {
|
type AgentDefaults struct {
|
||||||
|
|
@ -425,6 +427,13 @@ func (d *AgentDefaults) IsToolFeedbackEnabled() bool {
|
||||||
return d.ToolFeedback.Enabled
|
return d.ToolFeedback.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsSubagentToolFeedbackEnabled returns true when subagent turns should publish
|
||||||
|
// visible tool feedback. It defaults to true for backward compatibility when
|
||||||
|
// tool_feedback itself is enabled.
|
||||||
|
func (d *AgentDefaults) IsSubagentToolFeedbackEnabled() bool {
|
||||||
|
return d.ToolFeedback.Subagents == nil || *d.ToolFeedback.Subagents
|
||||||
|
}
|
||||||
|
|
||||||
// IsToolFeedbackSeparateMessagesEnabled returns true when each tool feedback
|
// IsToolFeedbackSeparateMessagesEnabled returns true when each tool feedback
|
||||||
// update should be sent as its own chat message instead of editing a single
|
// update should be sent as its own chat message instead of editing a single
|
||||||
// in-place progress message.
|
// in-place progress message.
|
||||||
|
|
@ -432,6 +441,10 @@ func (d *AgentDefaults) IsToolFeedbackSeparateMessagesEnabled() bool {
|
||||||
return d.ToolFeedback.SeparateMessages
|
return d.ToolFeedback.SeparateMessages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *AgentDefaults) GetToolFeedbackStyle() string {
|
||||||
|
return strings.TrimSpace(d.ToolFeedback.Style)
|
||||||
|
}
|
||||||
|
|
||||||
// GetModelName returns the effective model name for the agent defaults.
|
// GetModelName returns the effective model name for the agent defaults.
|
||||||
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
|
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
|
||||||
func (d *AgentDefaults) GetModelName() string {
|
func (d *AgentDefaults) GetModelName() string {
|
||||||
|
|
|
||||||
|
|
@ -1098,9 +1098,15 @@ func TestDefaultConfig_ToolFeedbackDisabled(t *testing.T) {
|
||||||
if cfg.Agents.Defaults.ToolFeedback.Enabled {
|
if cfg.Agents.Defaults.ToolFeedback.Enabled {
|
||||||
t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.Enabled should be false")
|
t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.Enabled should be false")
|
||||||
}
|
}
|
||||||
|
if !cfg.Agents.Defaults.IsSubagentToolFeedbackEnabled() {
|
||||||
|
t.Fatal("DefaultConfig().Agents.Defaults.IsSubagentToolFeedbackEnabled() should default to true")
|
||||||
|
}
|
||||||
if cfg.Agents.Defaults.ToolFeedback.SeparateMessages {
|
if cfg.Agents.Defaults.ToolFeedback.SeparateMessages {
|
||||||
t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.SeparateMessages should be false")
|
t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.SeparateMessages should be false")
|
||||||
}
|
}
|
||||||
|
if got := cfg.Agents.Defaults.GetToolFeedbackStyle(); got != "" {
|
||||||
|
t.Fatalf("DefaultConfig().Agents.Defaults.GetToolFeedbackStyle() = %q, want empty/raw default", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) {
|
func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) {
|
||||||
|
|
@ -1123,9 +1129,55 @@ func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) {
|
||||||
"agents.defaults.tool_feedback.enabled should remain false when unset in config file",
|
"agents.defaults.tool_feedback.enabled should remain false when unset in config file",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if !cfg.Agents.Defaults.IsSubagentToolFeedbackEnabled() {
|
||||||
|
t.Fatal("agents.defaults.tool_feedback.subagents should default to true when unset")
|
||||||
|
}
|
||||||
if cfg.Agents.Defaults.ToolFeedback.SeparateMessages {
|
if cfg.Agents.Defaults.ToolFeedback.SeparateMessages {
|
||||||
t.Fatal("agents.defaults.tool_feedback.separate_messages should remain false when unset in config file")
|
t.Fatal("agents.defaults.tool_feedback.separate_messages should remain false when unset in config file")
|
||||||
}
|
}
|
||||||
|
if got := cfg.Agents.Defaults.GetToolFeedbackStyle(); got != "" {
|
||||||
|
t.Fatalf("agents.defaults.tool_feedback.style = %q, want empty/raw default when unset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_ToolFeedbackStyle(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
if err := os.WriteFile(
|
||||||
|
configPath,
|
||||||
|
[]byte(`{"version":1,"agents":{"defaults":{"tool_feedback":{"enabled":true,"style":"working_summary"}}}}`),
|
||||||
|
0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error: %v", err)
|
||||||
|
}
|
||||||
|
if got := cfg.Agents.Defaults.GetToolFeedbackStyle(); got != "working_summary" {
|
||||||
|
t.Fatalf("agents.defaults.tool_feedback.style = %q, want working_summary", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_ToolFeedbackSubagentsFalse(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
if err := os.WriteFile(
|
||||||
|
configPath,
|
||||||
|
[]byte(`{"version":1,"agents":{"defaults":{"tool_feedback":{"enabled":true,"subagents":false}}}}`),
|
||||||
|
0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Agents.Defaults.IsSubagentToolFeedbackEnabled() {
|
||||||
|
t.Fatal("agents.defaults.tool_feedback.subagents = true, want false")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {
|
func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -30,6 +32,8 @@ func FormatArgsJSON(args map[string]any, prettyPrint, disableEscapeHTML bool) st
|
||||||
return strings.TrimSpace(buf.String())
|
return strings.TrimSpace(buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ToolFeedbackStyleWorkingSummary = "working_summary"
|
||||||
|
|
||||||
// FormatToolFeedbackMessage renders a tool feedback message for chat channels.
|
// FormatToolFeedbackMessage renders a tool feedback message for chat channels.
|
||||||
// It keeps the tool name on the first line for animation and can include both
|
// It keeps the tool name on the first line for animation and can include both
|
||||||
// a human explanation and the serialized tool arguments in the body.
|
// a human explanation and the serialized tool arguments in the body.
|
||||||
|
|
@ -57,6 +61,187 @@ func FormatToolFeedbackMessage(toolName, explanation, argsPreview string) string
|
||||||
return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, body)
|
return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatToolFeedbackMessageWithStyle renders alternate tool feedback styles.
|
||||||
|
// The working_summary style intentionally omits explanations and raw arguments:
|
||||||
|
// progress UI should not leak internal paths, large JSON blobs, secrets, or
|
||||||
|
// model-drafted reasoning-like text.
|
||||||
|
func FormatToolFeedbackMessageWithStyle(style, toolName, explanation, argsPreview string) string {
|
||||||
|
return FormatToolFeedbackMessageWithStyleAndTitle(style, "", toolName, explanation, argsPreview)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatToolFeedbackMessageWithStyleAndTitle(
|
||||||
|
style, title, toolName, explanation, argsPreview string,
|
||||||
|
) string {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(style), ToolFeedbackStyleWorkingSummary) {
|
||||||
|
return FormatWorkingSummaryToolFeedbackMessageWithTitle(title, toolName, argsPreview)
|
||||||
|
}
|
||||||
|
return FormatToolFeedbackMessage(toolName, explanation, argsPreview)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatWorkingSummaryToolFeedbackMessage(toolName, argsPreview string) string {
|
||||||
|
return FormatWorkingSummaryToolFeedbackMessageWithTitle("", toolName, argsPreview)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatWorkingSummaryToolFeedbackMessageWithTitle(title, toolName, argsPreview string) string {
|
||||||
|
toolName = strings.TrimSpace(toolName)
|
||||||
|
header := workingSummaryHeader(title)
|
||||||
|
if toolName == "" {
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf("• tool: `%s`", sanitizeToolFeedbackCodeSpan(toolName))
|
||||||
|
if summary := summarizeToolFeedbackArgs(toolName, argsPreview); summary != "" {
|
||||||
|
line += fmt.Sprintf(" — `%s`", sanitizeToolFeedbackCodeSpan(summary))
|
||||||
|
}
|
||||||
|
return header + "\n" + line
|
||||||
|
}
|
||||||
|
|
||||||
|
func workingSummaryHeader(title string) string {
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
if title == "" {
|
||||||
|
return "Working..."
|
||||||
|
}
|
||||||
|
return title + " working..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeToolFeedbackCodeSpan(text string) string {
|
||||||
|
return strings.ReplaceAll(text, "`", "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeToolFeedbackArgs(toolName, argsPreview string) string {
|
||||||
|
argsPreview = strings.TrimSpace(argsPreview)
|
||||||
|
if argsPreview == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var args map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(argsPreview), &args); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedToolName := strings.ToLower(strings.TrimSpace(toolName))
|
||||||
|
if strings.Contains(normalizedToolName, "exec") {
|
||||||
|
if command := firstStringArg(args, "command"); command != "" {
|
||||||
|
return truncateToolFeedbackSummary(summarizeExecCommand(command))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if isFileToolFeedbackTool(normalizedToolName) {
|
||||||
|
if summary := firstStringArg(args, "path", "file_path", "filepath"); summary != "" {
|
||||||
|
return truncateToolFeedbackSummary(summarizeFilePath(summary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFileToolFeedbackTool(toolName string) bool {
|
||||||
|
return strings.Contains(toolName, "read_file") ||
|
||||||
|
strings.Contains(toolName, "write_file") ||
|
||||||
|
strings.Contains(toolName, "edit_file") ||
|
||||||
|
strings.Contains(toolName, "append_file") ||
|
||||||
|
strings.Contains(toolName, "list_dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstStringArg(args map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value, ok := args[key]; ok {
|
||||||
|
if s, ok := value.(string); ok {
|
||||||
|
if normalized := normalizeToolFeedbackSummary(s); normalized != "" {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeToolFeedbackSummary(text string) string {
|
||||||
|
return redactToolFeedbackSecrets(strings.Join(strings.Fields(text), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeFilePath(path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned := filepath.Clean(path)
|
||||||
|
slashed := filepath.ToSlash(cleaned)
|
||||||
|
if idx := strings.LastIndex(slashed, "/workspace/"); idx >= 0 {
|
||||||
|
relative := strings.TrimPrefix(slashed[idx+len("/workspace/"):], "/")
|
||||||
|
if relative != "" && relative != "." {
|
||||||
|
return relative
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filepath.IsAbs(cleaned) {
|
||||||
|
return slashed
|
||||||
|
}
|
||||||
|
return filepath.Base(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateToolFeedbackSummary(text string) string {
|
||||||
|
const maxRunes = 96
|
||||||
|
runes := []rune(text)
|
||||||
|
if len(runes) <= maxRunes {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(runes[:maxRunes-3])) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeExecCommand(command string) string {
|
||||||
|
fields := strings.Fields(command)
|
||||||
|
for i := 0; i < len(fields); i++ {
|
||||||
|
token := strings.Trim(fields[i], `"'`)
|
||||||
|
if token == "" || isShellAssignment(token) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch token {
|
||||||
|
case "env", "command", "time", "timeout", "sudo":
|
||||||
|
continue
|
||||||
|
case "bash", "sh", "zsh", "fish", "python", "python3", "node", "deno", "bun", "uv", "uvx", "npx":
|
||||||
|
for j := i + 1; j < len(fields); j++ {
|
||||||
|
next := strings.Trim(fields[j], `"'`)
|
||||||
|
if next == "" || strings.HasPrefix(next, "-") || isShellAssignment(next) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return filepath.Base(next)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
default:
|
||||||
|
return filepath.Base(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isShellAssignment(token string) bool {
|
||||||
|
return strings.Contains(token, "=") && !strings.HasPrefix(token, "/") && !strings.HasPrefix(token, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
toolFeedbackSecretValuePatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)\bsk-(?:proj|or-v1)?-[A-Za-z0-9_-]{16,}`),
|
||||||
|
regexp.MustCompile(`\bAIza[A-Za-z0-9_-]{20,}`),
|
||||||
|
regexp.MustCompile(`\bmat_[A-Za-z0-9_-]{16,}`),
|
||||||
|
regexp.MustCompile(`\b\d{6,}:[A-Za-z0-9_-]{20,}`),
|
||||||
|
regexp.MustCompile(`\b(?:ghp|github_pat|glpat|xox[baprs])_[A-Za-z0-9_-]{16,}`),
|
||||||
|
}
|
||||||
|
toolFeedbackSecretKVPattern = regexp.MustCompile(`(?i)(\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|authorization)\b\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s&]+)`)
|
||||||
|
toolFeedbackSecretFlagPattern = regexp.MustCompile(`(?i)(--(?:api-key|access-token|auth-token|token|secret|password|authorization)(?:=|\s+))("[^"]*"|'[^']*'|[^\s&]+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func redactToolFeedbackSecrets(text string) string {
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, pattern := range toolFeedbackSecretValuePatterns {
|
||||||
|
text = pattern.ReplaceAllString(text, "[redacted]")
|
||||||
|
}
|
||||||
|
text = toolFeedbackSecretKVPattern.ReplaceAllString(text, "${1}[redacted]")
|
||||||
|
text = toolFeedbackSecretFlagPattern.ReplaceAllString(text, "${1}[redacted]")
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
// FitToolFeedbackMessage keeps tool feedback within a single outbound message.
|
// FitToolFeedbackMessage keeps tool feedback within a single outbound message.
|
||||||
// It preserves the first line when possible and truncates the explanation body
|
// It preserves the first line when possible and truncates the explanation body
|
||||||
// instead of letting the message be split into multiple chunks.
|
// instead of letting the message be split into multiple chunks.
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ func TestFormatToolFeedbackMessage(t *testing.T) {
|
||||||
"I will read README.md first to confirm the current project structure.",
|
"I will read README.md first to confirm the current project structure.",
|
||||||
"{\n \"path\": \"README.md\"\n}",
|
"{\n \"path\": \"README.md\"\n}",
|
||||||
)
|
)
|
||||||
want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure.\n```json\n{\n \"path\": \"README.md\"\n}\n```"
|
want := "🔧 `read_file`\nI will read README.md first to confirm the current project structure.\n```json\n{\n \"path\": \"README.md\"\n}\n```"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
@ -19,7 +19,7 @@ func TestFormatToolFeedbackMessage(t *testing.T) {
|
||||||
|
|
||||||
func TestFormatToolFeedbackMessage_EmptyExplanationShowsArgs(t *testing.T) {
|
func TestFormatToolFeedbackMessage_EmptyExplanationShowsArgs(t *testing.T) {
|
||||||
got := FormatToolFeedbackMessage("read_file", "", "{\n \"path\": \"README.md\"\n}")
|
got := FormatToolFeedbackMessage("read_file", "", "{\n \"path\": \"README.md\"\n}")
|
||||||
want := "\U0001f527 `read_file`\n```json\n{\n \"path\": \"README.md\"\n}\n```"
|
want := "🔧 `read_file`\n```json\n{\n \"path\": \"README.md\"\n}\n```"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
@ -35,12 +35,112 @@ func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) {
|
||||||
|
|
||||||
func TestFormatToolFeedbackMessage_EmptyExplanationAndArgsKeepsOnlyToolLine(t *testing.T) {
|
func TestFormatToolFeedbackMessage_EmptyExplanationAndArgsKeepsOnlyToolLine(t *testing.T) {
|
||||||
got := FormatToolFeedbackMessage("read_file", "", "")
|
got := FormatToolFeedbackMessage("read_file", "", "")
|
||||||
want := "\U0001f527 `read_file`"
|
want := "🔧 `read_file`"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFormatToolFeedbackMessageWithStyle_WorkingSummary(t *testing.T) {
|
||||||
|
got := FormatToolFeedbackMessageWithStyle(
|
||||||
|
"working_summary",
|
||||||
|
"read_file",
|
||||||
|
"I will read README.md first to confirm the current project structure.",
|
||||||
|
"{\n \"path\": \"README.md\"\n}",
|
||||||
|
)
|
||||||
|
want := "Working...\n• tool: `read_file` — `README.md`"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("FormatToolFeedbackMessageWithStyle() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatToolFeedbackMessageWithStyleAndTitle_WorkingSummary(t *testing.T) {
|
||||||
|
got := FormatToolFeedbackMessageWithStyleAndTitle(
|
||||||
|
"working_summary",
|
||||||
|
"DeepResearch",
|
||||||
|
"read_file",
|
||||||
|
"",
|
||||||
|
"{\"path\":\"README.md\"}",
|
||||||
|
)
|
||||||
|
want := "DeepResearch working...\n• tool: `read_file` — `README.md`"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("FormatToolFeedbackMessageWithStyleAndTitle() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatToolFeedbackMessageWithStyle_WorkingSummaryShowsFileBasenameOnly(t *testing.T) {
|
||||||
|
got := FormatToolFeedbackMessageWithStyle(
|
||||||
|
"working_summary",
|
||||||
|
"write_file",
|
||||||
|
"",
|
||||||
|
"{\"path\":\"/home/user/private/config.json\"}",
|
||||||
|
)
|
||||||
|
want := "Working...\n• tool: `write_file` — `config.json`"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("FormatToolFeedbackMessageWithStyle() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatToolFeedbackMessageWithStyle_WorkingSummaryShowsWorkspaceRelativeFile(t *testing.T) {
|
||||||
|
got := FormatToolFeedbackMessageWithStyle(
|
||||||
|
"working_summary",
|
||||||
|
"read_file",
|
||||||
|
"",
|
||||||
|
"{\"path\":\"/home/server/.picoclaw/spouse/workspace/memory/MEMORY.md\"}",
|
||||||
|
)
|
||||||
|
want := "Working...\n• tool: `read_file` — `memory/MEMORY.md`"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("FormatToolFeedbackMessageWithStyle() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatToolFeedbackMessageWithStyle_WorkingSummaryShowsExecCommand(t *testing.T) {
|
||||||
|
got := FormatToolFeedbackMessageWithStyle(
|
||||||
|
"working_summary",
|
||||||
|
"exec",
|
||||||
|
"",
|
||||||
|
"{\n \"action\": \"run\",\n \"command\": \"scripts/gog_me forms add-question FORM --title Name --type paragraph\",\n \"timeout\": 120\n}",
|
||||||
|
)
|
||||||
|
want := "Working...\n• tool: `exec` — `gog_me`"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("FormatToolFeedbackMessageWithStyle() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatToolFeedbackMessageWithStyle_WorkingSummaryShowsExecScriptNameOnly(t *testing.T) {
|
||||||
|
got := FormatToolFeedbackMessageWithStyle(
|
||||||
|
"working_summary",
|
||||||
|
"exec",
|
||||||
|
"",
|
||||||
|
"{\"command\":\"OPENAI_API_KEY=sk-proj-abcdefghijklmnopqrstuvwxyz0123456789 bash -lc /home/server/.picoclaw/main/workspace/tmp_add_questions_anya_form_api.sh --api-key sk-or-v1-abcdefghijklmnopqrstuvwxyz0123456789\"}",
|
||||||
|
)
|
||||||
|
want := "Working...\n• tool: `exec` — `tmp_add_questions_anya_form_api.sh`"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("FormatToolFeedbackMessageWithStyle() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatToolFeedbackMessageWithStyle_WorkingSummarySanitizesCodeSpan(t *testing.T) {
|
||||||
|
got := FormatToolFeedbackMessageWithStyle("working_summary", "read_file", "", "{\"path\":\"bad`path\"}")
|
||||||
|
want := "Working...\n• tool: `read_file` — `bad'path`"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("FormatToolFeedbackMessageWithStyle() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatToolFeedbackMessageWithStyle_WorkingSummaryOmitsNonFileToolArgs(t *testing.T) {
|
||||||
|
got := FormatToolFeedbackMessageWithStyle(
|
||||||
|
"working_summary",
|
||||||
|
"web_fetch",
|
||||||
|
"",
|
||||||
|
`{"url":"https://example.test/?token=mat_abcdefghijklmnopqrstuvwxyz0123456789"}`,
|
||||||
|
)
|
||||||
|
want := "Working...\n• tool: `web_fetch`"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("FormatToolFeedbackMessageWithStyle() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) {
|
func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) {
|
||||||
got := FitToolFeedbackMessage(
|
got := FitToolFeedbackMessage(
|
||||||
"\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.",
|
"\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue