Merge pull request #2828 from bogdanovich/fix/queued-voice-followups
fix(agent): transcribe queued voice follow-ups
This commit is contained in:
commit
af901617ac
3 changed files with 148 additions and 10 deletions
|
|
@ -182,6 +182,8 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
msg = al.prepareInboundMessageForAgent(ctx, msg)
|
||||||
|
|
||||||
// Another turn is already active (or reserved) for this session — enqueue
|
// Another turn is already active (or reserved) for this session — enqueue
|
||||||
if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{
|
if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
|
|
|
||||||
|
|
@ -102,9 +102,27 @@ func (al *AgentLoop) ProcessHeartbeat(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
func (al *AgentLoop) prepareInboundMessageForAgent(
|
||||||
|
ctx context.Context,
|
||||||
|
msg bus.InboundMessage,
|
||||||
|
) bus.InboundMessage {
|
||||||
msg = bus.NormalizeInboundMessage(msg)
|
msg = bus.NormalizeInboundMessage(msg)
|
||||||
|
|
||||||
|
var hadAudio bool
|
||||||
|
msg, hadAudio = al.transcribeAudioInMessage(ctx, msg)
|
||||||
|
|
||||||
|
// For audio messages the placeholder was deferred by the channel.
|
||||||
|
// Now that transcription (and optional feedback) is done, send it.
|
||||||
|
if hadAudio && al.channelManager != nil {
|
||||||
|
al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
||||||
|
msg = al.prepareInboundMessageForAgent(ctx, msg)
|
||||||
|
|
||||||
// Add message preview to log (show full content for error messages)
|
// Add message preview to log (show full content for error messages)
|
||||||
var logContent string
|
var logContent string
|
||||||
if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") {
|
if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") {
|
||||||
|
|
@ -123,15 +141,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
var hadAudio bool
|
|
||||||
msg, hadAudio = al.transcribeAudioInMessage(ctx, msg)
|
|
||||||
|
|
||||||
// For audio messages the placeholder was deferred by the channel.
|
|
||||||
// Now that transcription (and optional feedback) is done, send it.
|
|
||||||
if hadAudio && al.channelManager != nil {
|
|
||||||
al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route system messages to processSystemMessage
|
// Route system messages to processSystemMessage
|
||||||
if msg.Channel == "system" {
|
if msg.Channel == "system" {
|
||||||
return al.processSystemMessage(ctx, msg)
|
return al.processSystemMessage(ctx, msg)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/audio/asr"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||||
|
|
@ -477,6 +478,16 @@ func (p *lateSteeringProvider) GetDefaultModel() string {
|
||||||
return "late-steering-mock"
|
return "late-steering-mock"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fixedTranscriber struct {
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fixedTranscriber) Name() string { return "fixed" }
|
||||||
|
|
||||||
|
func (f *fixedTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*asr.TranscriptionResponse, error) {
|
||||||
|
return &asr.TranscriptionResponse{Text: f.text}, nil
|
||||||
|
}
|
||||||
|
|
||||||
type blockingDirectProvider struct {
|
type blockingDirectProvider struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
calls int
|
calls int
|
||||||
|
|
@ -840,6 +851,122 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAgentLoop_Run_QueuedVoiceMessageIsTranscribedBeforeSteering(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &lateSteeringProvider{
|
||||||
|
firstCallStarted: make(chan struct{}),
|
||||||
|
releaseFirstCall: make(chan struct{}),
|
||||||
|
}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
audioPath := filepath.Join(tmpDir, "voice.ogg")
|
||||||
|
if err := os.WriteFile(audioPath, []byte("fake audio"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write audio fixture: %v", err)
|
||||||
|
}
|
||||||
|
ref, err := store.Store(audioPath, media.MediaMeta{
|
||||||
|
Filename: "voice.ogg",
|
||||||
|
ContentType: "audio/ogg",
|
||||||
|
CleanupPolicy: media.CleanupPolicyForgetOnly,
|
||||||
|
}, "scope-voice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store audio fixture: %v", err)
|
||||||
|
}
|
||||||
|
al.SetMediaStore(store)
|
||||||
|
al.SetTranscriber(&fixedTranscriber{text: "and also two pieces of bread"})
|
||||||
|
|
||||||
|
runCtx, cancelRun := context.WithCancel(context.Background())
|
||||||
|
defer cancelRun()
|
||||||
|
|
||||||
|
runErrCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
runErrCh <- al.Run(runCtx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
first := bus.InboundMessage{
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "chat1",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user1",
|
||||||
|
},
|
||||||
|
Content: "first meal",
|
||||||
|
}
|
||||||
|
late := bus.InboundMessage{
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "chat1",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user1",
|
||||||
|
},
|
||||||
|
Content: "[voice]",
|
||||||
|
Media: []string{ref},
|
||||||
|
}
|
||||||
|
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
if err := msgBus.PublishInbound(pubCtx, first); err != nil {
|
||||||
|
t.Fatalf("publish first inbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-provider.firstCallStarted:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timeout waiting for first provider call to start")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := msgBus.PublishInbound(pubCtx, late); err != nil {
|
||||||
|
t.Fatalf("publish late voice inbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(provider.releaseFirstCall)
|
||||||
|
|
||||||
|
subCtx, subCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer subCancel()
|
||||||
|
select {
|
||||||
|
case <-msgBus.OutboundChan():
|
||||||
|
case <-subCtx.Done():
|
||||||
|
t.Fatal("expected outbound response")
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelRun()
|
||||||
|
select {
|
||||||
|
case err := <-runErrCh:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run returned error: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timeout waiting for Run to stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.mu.Lock()
|
||||||
|
secondMessages := append([]providers.Message(nil), provider.secondCallMessages...)
|
||||||
|
provider.mu.Unlock()
|
||||||
|
|
||||||
|
foundTranscribedVoice := false
|
||||||
|
for _, msg := range secondMessages {
|
||||||
|
if msg.Role == "user" && strings.Contains(msg.Content, "[voice: and also two pieces of bread]") {
|
||||||
|
foundTranscribedVoice = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundTranscribedVoice {
|
||||||
|
t.Fatalf("expected queued voice message to be transcribed before steering injection, got %#v", secondMessages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAgentLoop_Run_PendingStopStillContinuesQueuedFollowUp(t *testing.T) {
|
func TestAgentLoop_Run_PendingStopStillContinuesQueuedFollowUp(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue