Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions backend/internal/engine/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,54 @@ func TestExtractTraceLLMWithParent(t *testing.T) {
}
}

// TestExtractTraceLLMReasoning covers a client that sends reasoning:
// it becomes the chain of thought and the output moves to its own
// labelled line. Both are wrapped, reasoning is model-generated and no
// more trustworthy than the response.
func TestExtractTraceLLMReasoning(t *testing.T) {
ev := &pb.PairedEvent{
PairType: pb.PairType_PAIR_TYPE_LLM,
Agent: &pb.AgentContext{AgentId: "billing"},
Data: &pb.PairedEvent_Llm{
Llm: &pb.LlmPairData{
Model: "claude-opus-5",
Output: "Refund issued.",
Reasoning: "Order is 5 days old so the policy allows it.",
},
},
}
const guid = "test-guid"
got := extractTrace(ev, guid)
wantWrapped := []string{
`Chain of Thought: <adrian-untrusted id="test-guid">Order is 5 days old so the policy allows it.</adrian-untrusted id="test-guid">`,
`Response: <adrian-untrusted id="test-guid">Refund issued.</adrian-untrusted id="test-guid">`,
}
for _, w := range wantWrapped {
if !strings.Contains(got, w) {
t.Errorf("extractTrace missing line %q in:\n%s", w, got)
}
}
}

// TestExtractTraceLLMReasoningAbsentUnchanged pins the pre-reasoning
// rendering for clients that never set the field: output stays the
// chain of thought and no Response line appears.
func TestExtractTraceLLMReasoningAbsentUnchanged(t *testing.T) {
ev := &pb.PairedEvent{
PairType: pb.PairType_PAIR_TYPE_LLM,
Agent: &pb.AgentContext{AgentId: "billing"},
Data: &pb.PairedEvent_Llm{
Llm: &pb.LlmPairData{Model: "gpt-4o-mini", Output: "Refund issued."},
},
}
got := extractTrace(ev, "test-guid")
want := "\nClassify this agent trace:\n\n" +
`Chain of Thought: <adrian-untrusted id="test-guid">Refund issued.</adrian-untrusted id="test-guid">`
if got != want {
t.Errorf("rendering changed for a client without reasoning:\ngot: %q\nwant: %q", got, want)
}
}

// -----------------------------------------------------------------
// HTTPClient.Classify
// -----------------------------------------------------------------
Expand Down
13 changes: 11 additions & 2 deletions backend/internal/engine/trace.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,20 @@ func writeLLMSection(b *strings.Builder, llm *pb.LlmPairData, guid string) {
if llm == nil {
return
}
writeLabelled(b, "Chain of Thought: ", llm.Output, guid)
// Clients that send reasoning put it here; older ones send only output,
// which has always been rendered under this label.
cot := llm.Reasoning
if cot == "" {
cot = llm.Output
}
writeLabelled(b, "Chain of Thought: ", cot, guid)
if llm.Reasoning != "" {
writeLabelled(b, "Response: ", llm.Output, guid)
}
if len(llm.ToolCalls) == 0 {
return
}
if llm.Output != "" {
if cot != "" {
b.WriteString("\n")
}
b.WriteString("Tool Calls:\n")
Expand Down
19 changes: 16 additions & 3 deletions backend/internal/proto/event.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/internal/ws/pairing.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func pairedEventToJSON(ev *pb.PairedEvent) (string, error) {
view["llm"] = map[string]any{
"model": llm.Model,
"output": llm.Output,
"reasoning": llm.Reasoning,
"messages": chatMessagesToJSON(llm.Messages),
"tool_calls": toolCallsToJSON(llm.ToolCalls),
"usage": tokenUsageToJSON(llm.Usage),
Expand Down
47 changes: 47 additions & 0 deletions backend/internal/ws/pairing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 SecureAgentics

package ws

import (
"encoding/json"
"testing"

pb "github.com/secureagentics/Adrian/backend/internal/proto"
)

// TestPairedEventToJSONReasoning checks the reasoning field survives the
// proto to JSON conversion, the dashboard and the engine read the
// persisted payload rather than the wire message.
func TestPairedEventToJSONReasoning(t *testing.T) {
ev := &pb.PairedEvent{
EventId: "ev-1",
PairType: pb.PairType_PAIR_TYPE_LLM,
Data: &pb.PairedEvent_Llm{
Llm: &pb.LlmPairData{
Model: "claude-opus-5",
Output: "Refund issued.",
Reasoning: "Order is within the refund window.",
},
},
}
payload, err := pairedEventToJSON(ev)
if err != nil {
t.Fatalf("pairedEventToJSON: %v", err)
}
var got struct {
LLM struct {
Output string `json:"output"`
Reasoning string `json:"reasoning"`
} `json:"llm"`
}
if err := json.Unmarshal([]byte(payload), &got); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if got.LLM.Reasoning != "Order is within the refund window." {
t.Errorf("reasoning = %q, want it persisted", got.LLM.Reasoning)
}
if got.LLM.Output != "Refund issued." {
t.Errorf("output = %q, want it left alone", got.LLM.Output)
}
}
2 changes: 2 additions & 0 deletions backend/proto/event.proto
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ message LlmPairData {
repeated ToolCall tool_calls = 4;
// Token usage counters.
TokenUsage usage = 5;
// The model's reasoning / chain of thought.
string reasoning = 6;
}

// ToolPairData is the payload for PAIR_TYPE_TOOL events: one tool_start
Expand Down
11 changes: 3 additions & 8 deletions integrations/claude-code/adrian_cc/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,6 @@ async def _ws_send_event(
"policy_m3": policy.policy_m3,
"policy_m4": policy.policy_m4,
}
result["source_ack"] = sf.login_ack.source

# --- Send event ---
batch = pb.PairedEventBatch(events=[event])
Expand Down Expand Up @@ -754,8 +753,8 @@ async def _verify_connection() -> dict[str, Any]:
"""Connect and SessionLogin only, with no event and no verdict.

Confirms the URL is reachable and the API key authenticates. Returns
{ok, source_ack, mode_name} or {ok: False, error}. Never touches the key
value beyond the auth header.
{ok, mode_name} or {ok: False, error}. Never touches the key value
beyond the auth header.
"""
headers: dict[str, str] = {}
if ADRIAN_API_KEY:
Expand All @@ -782,7 +781,6 @@ async def _verify_connection() -> dict[str, Any]:
if sf.WhichOneof("frame") == "login_ack":
return {
"ok": True,
"source_ack": sf.login_ack.source,
"mode_name": _mode_name(sf.login_ack.policy.mode),
}
return {"ok": False, "error": "server did not send a LoginAck"}
Expand All @@ -802,10 +800,7 @@ def _handle_verify() -> None:
sys.exit(1)
res = asyncio.run(_verify_connection())
if res.get("ok"):
print(
f"OK: connected to {ADRIAN_WS_URL} "
f"(source_ack={res.get('source_ack')!r}, backend mode={res.get('mode_name')})"
)
print(f"OK: connected to {ADRIAN_WS_URL} (backend mode={res.get('mode_name')})")
sys.exit(0)
print(
f"FAIL: {res.get('error')} (check ADRIAN_WS_URL and ADRIAN_API_KEY in ~/.adrian/.env)"
Expand Down
Loading
Loading