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