Skip to content

Commit 5707542

Browse files
authored
fix(signals): treat raw terminal API errors as errored outcomes (#1275)
Outcome classification currently marks a session completed when its final assistant message is a raw unrecovered API error, because the assistant-ended branch only lowers confidence for give-up prose. This adds a dedicated terminal API-error discriminator before the generic assistant-completed branch and leaves the rest of the outcome ladder intact. User-ended sessions, repeated failure streaks, give-up phrases, and normal assistant answers keep their current classifications. The change stays in `internal/signals` with focused outcome regression coverage. The terminal transcript entry and local corpus check came from @godilley's issue report. Closes #1269 Co-authored-by: Rod Boev <rodboev@users.noreply.github.com>
1 parent 3205da8 commit 5707542

4 files changed

Lines changed: 128 additions & 1 deletion

File tree

internal/db/sessions.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ func scanSessionRow(rs rowScanner) (Session, error) {
182182
return s, err
183183
}
184184

185-
const CurrentQualitySignalVersion = 2
185+
const CurrentQualitySignalVersion = 3
186186

187187
// QualitySignals groups persisted deterministic quality-signal
188188
// columns for API callers while keeping the database representation

internal/signals/outcome.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ func ClassifyOutcome(in OutcomeInput) OutcomeResult {
4242
return OutcomeResult{"unknown", "low", false}
4343
}
4444

45+
if in.EndedWithRole == "assistant" && hasTerminalAPIErrorText(in.LastAssistantText) {
46+
if isRecent(in.LastActivity) {
47+
return OutcomeResult{"unknown", "low", true}
48+
}
49+
return OutcomeResult{"errored", "medium", false}
50+
}
51+
4552
if in.MessageCount == 2 && in.EndedWithRole == "assistant" {
4653
return OutcomeResult{"completed", "medium", false}
4754
}
@@ -93,3 +100,8 @@ func hasGiveUpPattern(text string) bool {
93100
}
94101
return false
95102
}
103+
104+
func hasTerminalAPIErrorText(text string) bool {
105+
lower := strings.ToLower(strings.TrimSpace(text))
106+
return strings.HasPrefix(lower, "api error:")
107+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package signals
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestClassifyOutcome_TerminalRawAPIError(t *testing.T) {
11+
got := ClassifyOutcome(OutcomeInput{
12+
MessageCount: 4,
13+
EndedWithRole: "assistant",
14+
LastAssistantText: "API Error: Unable to connect to API (ConnectionRefused)",
15+
LastActivity: time.Now().Add(-time.Hour),
16+
})
17+
18+
assert.Equal(t, OutcomeResult{
19+
Outcome: "errored",
20+
Confidence: "medium",
21+
}, got)
22+
}
23+
24+
func TestClassifyOutcome_TerminalRawAPIErrorTwoMessageSession(t *testing.T) {
25+
got := ClassifyOutcome(OutcomeInput{
26+
MessageCount: 2,
27+
EndedWithRole: "assistant",
28+
LastAssistantText: "API Error: Unable to connect to API (ConnectionRefused)",
29+
LastActivity: time.Now().Add(-time.Hour),
30+
})
31+
32+
assert.Equal(t, OutcomeResult{
33+
Outcome: "errored",
34+
Confidence: "medium",
35+
}, got)
36+
}
37+
38+
func TestClassifyOutcome_TerminalRawAPIErrorSingleMessageSession(t *testing.T) {
39+
got := ClassifyOutcome(OutcomeInput{
40+
MessageCount: 1,
41+
EndedWithRole: "assistant",
42+
LastAssistantText: "API Error: Unable to connect to API (ConnectionRefused)",
43+
LastActivity: time.Now().Add(-time.Hour),
44+
})
45+
46+
assert.Equal(t, OutcomeResult{
47+
Outcome: "errored",
48+
Confidence: "medium",
49+
}, got)
50+
}
51+
52+
func TestClassifyOutcome_TerminalRawAPIErrorFalsePositive(t *testing.T) {
53+
got := ClassifyOutcome(OutcomeInput{
54+
MessageCount: 4,
55+
EndedWithRole: "assistant",
56+
LastAssistantText: "The final line was `API Error: Unable to connect to API`, but the retry succeeded and here is the answer.",
57+
LastActivity: time.Now().Add(-time.Hour),
58+
})
59+
60+
assert.Equal(t, OutcomeResult{
61+
Outcome: "completed",
62+
Confidence: "medium",
63+
}, got)
64+
}
65+
66+
func TestClassifyOutcome_RecentTwoMessageAssistantSessionStillCompleted(t *testing.T) {
67+
got := ClassifyOutcome(OutcomeInput{
68+
MessageCount: 2,
69+
EndedWithRole: "assistant",
70+
LastAssistantText: "Here is the answer.",
71+
LastActivity: time.Now(),
72+
})
73+
74+
assert.Equal(t, OutcomeResult{
75+
Outcome: "completed",
76+
Confidence: "medium",
77+
}, got)
78+
}

internal/sync/recompute_memory_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"strings"
66
"testing"
7+
"time"
78

89
"github.com/stretchr/testify/assert"
910
"github.com/stretchr/testify/require"
@@ -114,6 +115,42 @@ func TestRecomputeSignalsDoesNotReleaseHeapDirectly(t *testing.T) {
114115
assert.Zero(t, calls)
115116
}
116117

118+
func TestBackfillSignalsRecomputesVersion2TerminalAPIErrorSession(t *testing.T) {
119+
fx := newEngineFixture(t)
120+
ctx := context.Background()
121+
const id = "api-stale"
122+
endedAt := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)
123+
124+
require.NoError(t, fx.db.UpsertSession(db.Session{
125+
ID: id, Project: "proj", Machine: "m", Agent: "claude",
126+
MessageCount: 2, UserMessageCount: 1, EndedAt: &endedAt,
127+
}))
128+
require.NoError(t, fx.db.ReplaceSessionMessages(id, []db.Message{
129+
{SessionID: id, Ordinal: 0, Role: "user", Content: "hello"},
130+
{
131+
SessionID: id, Ordinal: 1, Role: "assistant",
132+
Content: "API Error: Unable to connect to API (ConnectionRefused)",
133+
},
134+
}))
135+
require.NoError(t, fx.db.UpdateSessionSignals(id, db.SessionSignalUpdate{
136+
Outcome: "completed",
137+
OutcomeConfidence: "medium",
138+
QualitySignals: db.QualitySignals{
139+
Version: 2,
140+
},
141+
}))
142+
143+
require.NoError(t,
144+
fx.db.BackfillSignals(ctx, fx.engine.BackfillSignalComputer()))
145+
146+
sess, err := fx.db.GetSessionFull(ctx, id)
147+
require.NoError(t, err)
148+
require.NotNil(t, sess)
149+
assert.Equal(t, db.CurrentQualitySignalVersion, sess.QualitySignalVersion)
150+
assert.Equal(t, "errored", sess.Outcome)
151+
assert.Equal(t, "medium", sess.OutcomeConfidence)
152+
}
153+
117154
func TestRecomputeHeapReleaserSkipsSmallSessions(t *testing.T) {
118155
oldThreshold := recomputeHeapReleaseThreshold
119156
oldFree := freeRecomputeHeap

0 commit comments

Comments
 (0)