fix(agent): pi agent final output excludes intermediate steps (#4894) (MUL-4030)

* fix(agent): pi agent final output excludes intermediate steps

Updated PI agent to only retain the final result in JSON output.

Previously, `text_delta` included both intermediate steps and final
content.

Now, output is reset on each `text_start` to concatenate only the
final text.

* fix(agent) Replace `message_update.text_start` with `turn_start` event. add test

`turn_start` begins a new turn, Reset output on it to exclude
intermediate texts.

a1b336d73e/packages/coding-agent/docs/rpc.md (events)
This commit is contained in:
etern
2026-07-06 15:34:22 +08:00
committed by GitHub
parent a48b6f70ef
commit e3de28ecd7
2 changed files with 70 additions and 0 deletions

View File

@@ -282,6 +282,10 @@ func (b *piBackend) Execute(ctx context.Context, prompt string, opts ExecOptions
case "agent_start":
trySend(msgCh, Message{Type: MessageStatus, Status: "running"})
case "turn_start":
output.Reset()
textBuffer.Reset()
case "message_update":
if evt.AssistantMessageEvent == nil {
continue

View File

@@ -119,6 +119,72 @@ func TestPiExecuteAttachesStdinPipe(t *testing.T) {
}
}
// piEventStreamScript builds a sh script that prints each JSON event on
// its own stdout line. Fixtures must not contain single quotes.
func piEventStreamScript(events []string) string {
var b strings.Builder
b.WriteString("#!/bin/sh\n")
for _, e := range events {
b.WriteString("printf '%s\\n' '")
b.WriteString(e)
b.WriteString("'\n")
}
return b.String()
}
// TestPiExecuteRetainsOnlyLastTurnOutput verifies turn_start resets the
// output buffer so Result.Output keeps only the final turn's text.
func TestPiExecuteRetainsOnlyLastTurnOutput(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
events := []string{
`{"type":"agent_start"}`,
`{"type":"turn_start"}`,
`{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"intermediate"}}`,
`{"type":"tool_execution_start","toolCallId":"call_1","toolName":"bash","args":{"command":"echo hi"}}`,
`{"type":"tool_execution_end","toolCallId":"call_1","toolName":"bash","result":{"content":[{"type":"text","text":"hi"}]},"isError":false}`,
`{"type":"turn_end","message":{"role":"assistant","model":"test","usage":{"input":1,"output":1}}}`,
`{"type":"turn_start"}`,
`{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"final"}}`,
`{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":" "}}`,
`{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"answer"}}`,
`{"type":"turn_end","message":{"role":"assistant","model":"test","usage":{"input":2,"output":2}}}`,
}
fakePath := filepath.Join(t.TempDir(), "pi")
writeTestExecutable(t, fakePath, []byte(piEventStreamScript(events)))
backend, err := New("pi", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new pi backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result := <-session.Result:
if result.Status != "completed" {
t.Fatalf("expected status=completed, got %q (error=%q)", result.Status, result.Error)
}
if result.Output != "final answer" {
t.Fatalf("Output: got %q, want %q", result.Output, "final answer")
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
func TestStripPiToolCallMarkup(t *testing.T) {
tests := map[string]string{
`before call:bash{command:<|"|>cd repo/path && ls -F<|"|>}<tool_call|> after`: "before after",