Conversation
|
✅ Preview binaries are ready! To test with modules: |
| // Update the last message (the streaming agent response) | ||
| if len(c.messages) > 0 { | ||
| c.messages[len(c.messages)-1].Message = c.streamingResponse.String() | ||
| } |
There was a problem hiding this comment.
Can we guarantee this behaves correctly? (i.e the final message definitely is an agent message?)
Both Opus 4.6 and Gemini 3.1 Pro believe there is a possible bug here:
- User sends prompt,
Send()appends user message withId: 0and placeholder agent message withId: 1 executePromptstarts blocking onPrompt- Timeout or error occurs.
executePromptcatches and removes agent messagec.messages[:len(c.messages)-1] - Possible delayed chunk arrives.
handleChunkunconditionally updatesc.messages[len(c.messages)-1]which is now the user's message
It wrote this test to "verify" it
func Test_Bug1_LateChunkOverwritesUserMessageAfterError(t *testing.T) {
mClock := quartz.NewMock(t)
mock := newMockAgentIO()
started, done := mock.BlockWrite()
conv := acpio.NewACPConversation(context.Background(), mock, nil, nil, nil, mClock)
conv.Start(context.Background())
// Step 1: Send user message "hello"
// This creates messages: [{Id:0, User, "hello"}, {Id:1, Agent, ""}]
err := conv.Send(screentracker.MessagePartText{Content: "hello"})
require.NoError(t, err)
<-started
// Verify initial state
messages := conv.Messages()
require.Len(t, messages, 2)
assert.Equal(t, screentracker.ConversationRoleUser, messages[0].Role)
assert.Equal(t, "hello", messages[0].Message)
assert.Equal(t, screentracker.ConversationRoleAgent, messages[1].Role)
// Step 2: Make Write() return an error, which triggers error handling in
// executePrompt. It will remove the agent placeholder, leaving only the
// user message: [{Id:0, User, "hello"}]
mock.mu.Lock()
mock.writeErr = assert.AnError
mock.mu.Unlock()
close(done)
// Wait for error processing to complete
require.Eventually(t, func() bool {
return conv.Status() == screentracker.ConversationStatusStable
}, 100*time.Millisecond, 5*time.Millisecond)
// Verify: agent message was removed, only user message remains
messages = conv.Messages()
require.Len(t, messages, 1, "agent placeholder should be removed after error")
assert.Equal(t, screentracker.ConversationRoleUser, messages[0].Role)
assert.Equal(t, "hello", messages[0].Message)
// Step 3: Simulate a late chunk arriving (as if the agent process sent
// data through the pipe after our timeout/error cancelled the prompt).
// The onChunk callback is still registered and will call handleChunk.
mock.SimulateChunks("late response data")
// Step 4: Check if the user message was overwritten.
messages = conv.Messages()
require.Len(t, messages, 1, "no new messages should appear from a late chunk")
// BUG VALIDATION: The user message should still be "hello", but
// handleChunk unconditionally updates messages[len(messages)-1] which
// now points to the user message.
assert.Equal(t, "hello", messages[0].Message,
"BUG CONFIRMED: user message was overwritten by late chunk (got %q)", messages[0].Message)
assert.Equal(t, screentracker.ConversationRoleUser, messages[0].Role,
"message role should still be User")
}I'm not entirely sure if this scenario would play out in the real world but I think there is no harm being defensive with ensuring the latest message is an agent message.
| Id: len(c.messages), | ||
| Role: st.ConversationRoleUser, | ||
| Message: message, | ||
| Time: c.clock.Now(), | ||
| }) | ||
| // Add placeholder for streaming agent response | ||
| c.messages = append(c.messages, st.ConversationMessage{ | ||
| Id: len(c.messages), |
There was a problem hiding this comment.
As we have logic for removing placeholder messages I'd like to confirm something: Do we ever return placeholders messages to the user? If we do we might end up re-using an ID and that could be a little funky.
| // Use a timeout to prevent hanging indefinitely | ||
| promptCtx, cancel := context.WithTimeout(a.ctx, DefaultPromptTimeout) | ||
| defer cancel() | ||
|
|
||
| resp, err := a.conn.Prompt(promptCtx, acp.PromptRequest{ | ||
| SessionId: a.sessionID, | ||
| Prompt: []acp.ContentBlock{acp.TextBlock(text)}, | ||
| }) | ||
| if err != nil { | ||
| a.logger.Error("Prompt failed", "error", err) | ||
| return 0, err | ||
| } |
There was a problem hiding this comment.
Are we okay with possibly killing a running prompt (that takes more than 5 minutes)? I know some LLMs can be quite slow (tok/s).
Relates to coder/internal#1333
Adds
/x/acpiopackage🤖 Written by Opus 4.5 using Mux