From 94654c3ec39b372db95dafd39643e7bc3222e2b7 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 9 Aug 2026 09:58:00 -0400 Subject: [PATCH] fix(gateway): avoid duplicating typed status message in output (#254) Build and Lint unconditionally prepended the typed runtime status message to the raw agent output. Agents that already fold the actionable message into raw output (as Generic does) then surfaced it twice. Compose via composeStatusOutput, which only prepends the status message when the raw output does not already carry it, generically for all agents. Distinct status context and native output are preserved. Co-Authored-By: Claude Opus 4.8 --- pkg/gateway/server.go | 19 ++++++++- pkg/gateway/server_test.go | 86 +++++++++++++++++++++++++++++++++++--- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/pkg/gateway/server.go b/pkg/gateway/server.go index b0ab41d5..3c96344e 100644 --- a/pkg/gateway/server.go +++ b/pkg/gateway/server.go @@ -1444,6 +1444,21 @@ func (s *Server) DiscoverCodeUnits(ctx context.Context, req *gatewayv1.DiscoverC // ─── Build / Lint / Test ───────────────────────────────────── +// composeStatusOutput surfaces a typed runtime status message alongside raw +// agent output. Agents that already fold the actionable message into raw output +// (as Generic does) would otherwise show it twice, so the message is only +// prepended when the raw output does not already carry it. +func composeStatusOutput(message, output string) string { + message = strings.TrimSpace(message) + if message == "" || strings.Contains(output, message) { + return output + } + if output == "" { + return message + } + return message + "\n" + output +} + func (s *Server) Build(ctx context.Context, _ *gatewayv1.BuildRequest) (*gatewayv1.BuildResponse, error) { service, err := s.executionServiceBehavior() if err != nil { @@ -1456,7 +1471,7 @@ func (s *Server) Build(ctx context.Context, _ *gatewayv1.BuildRequest) (*gateway success := resp.Status != nil && resp.Status.State == runtimev0.BuildStatus_SUCCESS output := resp.Output if !success && resp.Status != nil { - output = resp.Status.Message + "\n" + output + output = composeStatusOutput(resp.Status.Message, output) } var buildErrors []*gatewayv1.BuildError if !success { @@ -1477,7 +1492,7 @@ func (s *Server) Lint(ctx context.Context, _ *gatewayv1.LintRequest) (*gatewayv1 success := resp.Status != nil && resp.Status.State == runtimev0.LintStatus_SUCCESS output := resp.Output if !success && resp.Status != nil { - output = resp.Status.Message + "\n" + output + output = composeStatusOutput(resp.Status.Message, output) } var lintErrors []*gatewayv1.BuildError if !success { diff --git a/pkg/gateway/server_test.go b/pkg/gateway/server_test.go index addd9158..0b150d23 100644 --- a/pkg/gateway/server_test.go +++ b/pkg/gateway/server_test.go @@ -56,7 +56,9 @@ type mockCodeClient struct { } type mockRuntimeClient struct { - testFn func(ctx context.Context, in *runtimev0.TestRequest, opts ...grpc.CallOption) (*runtimev0.TestResponse, error) + testFn func(ctx context.Context, in *runtimev0.TestRequest, opts ...grpc.CallOption) (*runtimev0.TestResponse, error) + buildFn func(ctx context.Context, in *runtimev0.BuildRequest, opts ...grpc.CallOption) (*runtimev0.BuildResponse, error) + lintFn func(ctx context.Context, in *runtimev0.LintRequest, opts ...grpc.CallOption) (*runtimev0.LintResponse, error) } type mockServiceExecution struct { @@ -172,8 +174,11 @@ func (m *mockRuntimeClient) Stop(context.Context, *runtimev0.StopRequest, ...grp func (m *mockRuntimeClient) Destroy(context.Context, *runtimev0.DestroyRequest, ...grpc.CallOption) (*runtimev0.DestroyResponse, error) { return nil, fmt.Errorf("not exercised in mock") } -func (m *mockRuntimeClient) Build(context.Context, *runtimev0.BuildRequest, ...grpc.CallOption) (*runtimev0.BuildResponse, error) { - return nil, fmt.Errorf("not exercised in mock") +func (m *mockRuntimeClient) Build(ctx context.Context, in *runtimev0.BuildRequest, opts ...grpc.CallOption) (*runtimev0.BuildResponse, error) { + if m.buildFn == nil { + return nil, fmt.Errorf("Build not configured") + } + return m.buildFn(ctx, in, opts...) } func (m *mockRuntimeClient) Test(ctx context.Context, in *runtimev0.TestRequest, opts ...grpc.CallOption) (*runtimev0.TestResponse, error) { if m.testFn == nil { @@ -181,8 +186,11 @@ func (m *mockRuntimeClient) Test(ctx context.Context, in *runtimev0.TestRequest, } return m.testFn(ctx, in, opts...) } -func (m *mockRuntimeClient) Lint(context.Context, *runtimev0.LintRequest, ...grpc.CallOption) (*runtimev0.LintResponse, error) { - return nil, fmt.Errorf("not exercised in mock") +func (m *mockRuntimeClient) Lint(ctx context.Context, in *runtimev0.LintRequest, opts ...grpc.CallOption) (*runtimev0.LintResponse, error) { + if m.lintFn == nil { + return nil, fmt.Errorf("Lint not configured") + } + return m.lintFn(ctx, in, opts...) } func (m *mockRuntimeClient) Information(context.Context, *runtimev0.InformationRequest, ...grpc.CallOption) (*runtimev0.InformationResponse, error) { return nil, fmt.Errorf("not exercised in mock") @@ -601,6 +609,74 @@ func TestTestPreservesStructuredRuntimeFields(t *testing.T) { } } +func TestBuildDoesNotDuplicateStatusMessageInOutput(t *testing.T) { + const msg = "build not available: generic agent has no language knowledge" + rt := &mockRuntimeClient{ + buildFn: func(_ context.Context, _ *runtimev0.BuildRequest, _ ...grpc.CallOption) (*runtimev0.BuildResponse, error) { + return &runtimev0.BuildResponse{ + Output: msg, + Status: &runtimev0.BuildStatus{State: runtimev0.BuildStatus_ERROR, Message: msg}, + }, nil + }, + } + s := newTestServerWithRuntime(rt) + + resp, err := s.Build(context.Background(), &gatewayv1.BuildRequest{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + if resp.Success { + t.Fatal("Success = true, want false") + } + if resp.Output != msg { + t.Fatalf("Output = %q, want the message surfaced once", resp.Output) + } +} + +func TestBuildPrependsDistinctStatusMessage(t *testing.T) { + rt := &mockRuntimeClient{ + buildFn: func(_ context.Context, _ *runtimev0.BuildRequest, _ ...grpc.CallOption) (*runtimev0.BuildResponse, error) { + return &runtimev0.BuildResponse{ + Output: "compiler: undefined symbol", + Status: &runtimev0.BuildStatus{State: runtimev0.BuildStatus_ERROR, Message: "build failed"}, + }, nil + }, + } + s := newTestServerWithRuntime(rt) + + resp, err := s.Build(context.Background(), &gatewayv1.BuildRequest{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + if resp.Output != "build failed\ncompiler: undefined symbol" { + t.Fatalf("Output = %q, want status context preserved ahead of native output", resp.Output) + } +} + +func TestLintDoesNotDuplicateStatusMessageInOutput(t *testing.T) { + const msg = "lint not available: generic agent has no language knowledge" + rt := &mockRuntimeClient{ + lintFn: func(_ context.Context, _ *runtimev0.LintRequest, _ ...grpc.CallOption) (*runtimev0.LintResponse, error) { + return &runtimev0.LintResponse{ + Output: msg, + Status: &runtimev0.LintStatus{State: runtimev0.LintStatus_ERROR, Message: msg}, + }, nil + }, + } + s := newTestServerWithRuntime(rt) + + resp, err := s.Lint(context.Background(), &gatewayv1.LintRequest{}) + if err != nil { + t.Fatalf("Lint: %v", err) + } + if resp.Success { + t.Fatal("Success = true, want false") + } + if resp.Output != msg { + t.Fatalf("Output = %q, want the message surfaced once", resp.Output) + } +} + func TestFix(t *testing.T) { mock := &mockCodeClient{ fixFn: func(_ context.Context, in *codev0.FixRequest, _ ...grpc.CallOption) (*codev0.FixResponse, error) {