1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
//! The end of a turn: recording it, folding in usage, and telling the user
//! whether it finished, failed, or stopped because they asked.
use super::super::super::transcript::BlockKind;
use super::super::super::types::App;
use super::super::super::usage_footer;
use crate::interactive::session_state::SessionState;
use saya_agent::AgentOutput;
impl App {
/// Handles a settled stream: the `Done` arm of the drain loop, lifted out
/// so `drain.rs` stays under the size cap.
pub(super) fn settle_done(
&mut self,
result: Result<AgentOutput, String>,
state: &mut SessionState,
prompt: String,
) {
// Whether the user asked this stream to stop: Esc fires the
// token before the worker's settle arrives. Read before
// the match borrows `result`, while the stream exists;
// the finish block clears it after the batch.
let stop_confirmed = self
.request
.stream
.as_ref()
.is_some_and(|stream| stream.cancel.is_cancelled());
// Whether this run continued after the provider capped a
// response mid-answer: the loop re-instructs, the partial
// stays discarded, and the resume anchors live in earlier
// tool results. A finished turn that continued is not a
// turn boundary for compaction — compacting there risks
// the very lines the resume needs. `TurnReset` (retried
// transport attempts) re-streams the same answer and is
// not a continuation; only the output-cap re-instruction
// counts. `output.truncated` is that signal: it is set
// only when a run salvaged an answer after a ceiling ran
// out, never on a natural completion.
let continued = result
.as_ref()
.map(|output| output.truncated)
.unwrap_or(false);
match result {
Ok(output) => {
state.task_list = self.session.tasks().current();
state.record_turn(
prompt.clone(),
output.answer.clone(),
output.used_bounded_sql_query,
output.tool_metadata.clone(),
);
let usage = &output.usage;
// Accumulate into the session total before the
// footer is built, so its session segment
// includes the turn it reports. `record` applies
// the same zero-guard as the push below, so a
// silent provider's all-zero usage adds nothing.
state.usage.record(usage);
if usage.input_tokens > 0 || usage.output_tokens > 0 {
// The user-declared window is a fact about
// this deployment and wins over the table;
// otherwise the table answers for the live
// model, and `None` for a model it does not
// know stays absent in the footer.
let window = self
.runtime
.resolved
.ai
.context_window_tokens
.or_else(|| saya_config::context_window_tokens(&state.model));
self.transcript.push(
BlockKind::System,
usage_footer::transcript_footer(
usage,
&state.usage.answering,
self.request.last_answering_input,
window,
),
);
// The one-shot context warning: fire on the
// upward crossing of the warn threshold, stay
// silent while above it, re-arm below it. No
// window (or no per-call report) means no
// percentage, so the flag is untouched —
// absence is not zero. `off` silences it
// along with the automatic trigger.
if crate::interactive::auto_compact::warning_enabled(
self.runtime.resolved.ai.compaction,
) && let Some(percent) = saya_agent::context_utilisation_percent(
self.request.last_answering_input,
window,
) {
if percent >= saya_agent::CONTEXT_WARN_PERCENT {
if !state.context_warned {
state.context_warned = true;
if let Some(window) = window
&& let Some(notice) =
usage_footer::context_warn_notice(percent, window)
{
self.transcript.push(BlockKind::System, notice);
}
}
} else {
state.context_warned = false;
}
}
// The automatic trigger: the same numerator
// and window as the warning and the footer —
// no second measurement — read at the turn
// boundary only, when no continuation is
// outstanding and no compaction is running.
// Unknown window or no report never fires.
// Capture the values before the finish block
// below clears the per-turn numerator.
let auto_input = self.request.last_answering_input;
let fire = crate::interactive::auto_compact::should_auto_compact(
&crate::interactive::auto_compact::AutoCompactInput {
mode: self.runtime.resolved.ai.compaction,
answering_input: auto_input,
window,
continued,
compact_running: self.compact_task.is_some(),
auto_failed: state.auto_compact_failed,
},
);
if fire {
crate::interactive::compact_task::start_automatic(self, state);
}
}
// Fold the extraction call's usage into a separate
// learning total. `learning_usage` is `None` when
// no extraction ran or it produced no response, so
// a session with learning disabled records nothing
// here — the answering total is unchanged.
state.usage.record_learning(output.learning_usage);
}
Err(error) => {
// The stop's confirmation, not a failure: the
// token says this app asked, the worker's settle
// text says the worker confirmed — kept work
// stays kept — so it renders as a System block.
// Both must agree: a genuine failure arriving
// with the token fired keeps its error block.
if stop_confirmed && error == "request cancelled" {
self.transcript
.push(BlockKind::System, "Stopped. Completed work is kept.");
} else {
self.transcript.push(BlockKind::Error, error);
}
}
}
}
}