Skip to content

Comments

feat: add /x/acpio package#188

Open
johnstcn wants to merge 1 commit intomainfrom
cj/exp/acp-a
Open

feat: add /x/acpio package#188
johnstcn wants to merge 1 commit intomainfrom
cj/exp/acp-a

Conversation

@johnstcn
Copy link
Member

@johnstcn johnstcn commented Feb 19, 2026

Relates to coder/internal#1333

Adds /x/acpio package

🤖 Written by Opus 4.5 using Mux

@github-actions
Copy link

✅ Preview binaries are ready!

To test with modules: agentapi_version = "agentapi_188" or download from: https://github.com/coder/agentapi/releases/tag/agentapi_188

@johnstcn johnstcn marked this pull request as ready for review February 19, 2026 09:58
Comment on lines +183 to +186
// Update the last message (the streaming agent response)
if len(c.messages) > 0 {
c.messages[len(c.messages)-1].Message = c.streamingResponse.String()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with Id: 0 and placeholder agent message with Id: 1
  • executePrompt starts blocking on Prompt
  • Timeout or error occurs. executePrompt catches and removes agent message c.messages[:len(c.messages)-1]
  • Possible delayed chunk arrives. handleChunk unconditionally updates c.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.

Comment on lines +107 to +114
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),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +216 to +227
// 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
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants