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
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
use serde::{Deserialize, Serialize};
use tail_fin_common::TailFinError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeminiResponse {
pub response: String,
/// `c_<id>` — committed conversation id. Present when Gemini
/// persists the turn (i.e. most logged-in cases).
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
/// `r_<id>` — this turn's response id. Needed to continue the
/// conversation in a follow-up request.
#[serde(skip_serializing_if = "Option::is_none")]
pub response_id: Option<String>,
/// `rc_<id>` — the specific choice id (Gemini sometimes offers
/// multiple drafts; `rc_` pins which one we're continuing from).
#[serde(skip_serializing_if = "Option::is_none")]
pub choice_id: Option<String>,
}
impl GeminiResponse {
/// True iff all three ids are present and non-trivially shaped.
pub fn can_continue(&self) -> bool {
self.require_continuation().is_ok()
}
/// Destructure the triple, returning an error listing which fields
/// are missing. `ask_continue` calls this so users get a specific
/// message instead of the request failing opaquely on the server.
///
/// **Trust model**: this runs on ids that were extracted from a
/// real server response (by `extract_turn_ids`, which already
/// enforces the `c_`/`r_`/`rc_` prefixes at extraction time).
/// Re-validating prefixes here would mean rejecting ids *Gemini
/// itself gave us* if they ever change shape — a regression risk
/// for zero benefit. The strict prefix check belongs on the
/// untrusted CLI/library-input path (see
/// `GeminiClient::validate_continuation_ids`), not here.
///
/// **Caveat for external callers**: `GeminiResponse` derives
/// `Deserialize` and exposes public fields, so a caller that
/// round-trips a stashed response through JSON/disk (or constructs
/// one manually) bypasses `extract_turn_ids`'s prefix enforcement.
/// Bogus ids take one wasted network round-trip — the stale-cid
/// guard in `ask_inner` catches the server-side rejection.
pub fn require_continuation(&self) -> Result<(&str, &str, &str), TailFinError> {
let cid = non_trivial(self.conversation_id.as_deref());
let rid = non_trivial(self.response_id.as_deref());
let rcid = non_trivial(self.choice_id.as_deref());
match (cid, rid, rcid) {
(Some(c), Some(r), Some(rc)) => Ok((c, r, rc)),
_ => {
let mut bad = Vec::new();
if cid.is_none() {
bad.push("conversation_id");
}
if rid.is_none() {
bad.push("response_id");
}
if rcid.is_none() {
bad.push("choice_id");
}
Err(TailFinError::Api(format!(
"cannot continue: response is missing {}",
bad.join(", ")
)))
}
}
}
}
/// Return the str iff it's present, non-empty after trim, and not the
/// literal `"null"` (common artefact of `jq -r` on an absent field).
/// Lenient by design — see `require_continuation` trust-model note.
fn non_trivial(s: Option<&str>) -> Option<&str> {
let s = s?;
let t = s.trim();
if t.is_empty() || t == "null" {
None
} else {
Some(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serde_roundtrip_full() {
let r = GeminiResponse {
response: "hello".into(),
conversation_id: Some("c_abc".into()),
response_id: Some("r_def".into()),
choice_id: Some("rc_ghi".into()),
};
let s = serde_json::to_string(&r).unwrap();
let back: GeminiResponse = serde_json::from_str(&s).unwrap();
assert_eq!(back.conversation_id.as_deref(), Some("c_abc"));
assert_eq!(back.response_id.as_deref(), Some("r_def"));
assert_eq!(back.choice_id.as_deref(), Some("rc_ghi"));
}
#[test]
fn omits_missing_ids() {
let r = GeminiResponse {
response: "hi".into(),
conversation_id: None,
response_id: None,
choice_id: None,
};
let s = serde_json::to_string(&r).unwrap();
assert!(!s.contains("conversation_id"));
assert!(!s.contains("response_id"));
assert!(!s.contains("choice_id"));
}
#[test]
fn can_continue_requires_all_three_ids() {
let mut r = GeminiResponse {
response: "".into(),
conversation_id: Some("c_ok".into()),
response_id: Some("r_ok".into()),
choice_id: Some("rc_ok".into()),
};
assert!(r.can_continue());
r.choice_id = None;
assert!(!r.can_continue());
}
#[test]
fn require_continuation_lists_missing_fields() {
let r = GeminiResponse {
response: "".into(),
conversation_id: Some("c_valid".into()),
response_id: None,
choice_id: None,
};
let err = r.require_continuation().unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("response_id"), "got: {msg}");
assert!(msg.contains("choice_id"), "got: {msg}");
assert!(!msg.contains(" conversation_id"), "got: {msg}");
}
#[test]
fn require_continuation_rejects_literal_null_strings() {
// jq -r on an absent field prints "null" — treat as missing.
let r = GeminiResponse {
response: "".into(),
conversation_id: Some("null".into()),
response_id: Some("null".into()),
choice_id: Some("null".into()),
};
assert!(!r.can_continue());
}
#[test]
fn require_continuation_rejects_empty_strings() {
let r = GeminiResponse {
response: "".into(),
conversation_id: Some("".into()),
response_id: Some("r_ok".into()),
choice_id: Some("rc_ok".into()),
};
assert!(!r.can_continue());
}
#[test]
fn require_continuation_accepts_ids_without_expected_prefix() {
// Forward-compat: if Gemini ever changes id shape, we don't
// want to reject ids the server itself handed us. Prefix
// enforcement lives on the untrusted-input path (see
// `validate_continuation_ids` in client.rs), not here.
let r = GeminiResponse {
response: "".into(),
conversation_id: Some("CID_uppercase".into()),
response_id: Some("some_new_shape".into()),
choice_id: Some("also_different".into()),
};
assert!(r.can_continue());
}
#[test]
fn require_continuation_trims_whitespace() {
let r = GeminiResponse {
response: "".into(),
conversation_id: Some(" ".into()),
response_id: Some("r_ok".into()),
choice_id: Some("rc_ok".into()),
};
assert!(!r.can_continue());
}
}