Skip to content

Commit 4feb621

Browse files
Shahinyanmclaude
andcommitted
fix(complete): tolerate non-JSON enrich replies; never abort finalize
The backfill model sometimes answers with prose instead of the JSON array — e.g. continuing the transcript's own dialogue ('Контекст в норме... Что дальше?'). The parse error aborted the whole `complete`, losing the retitle and close. Backfill is best-effort: skip an unparseable chunk reply (warn), extract a JSON array even when wrapped in prose, and re-assert 'output ONLY the JSON array, do not continue the transcript' after the transcript. Retitle/close run regardless of what enrich recovers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 552ae5b commit 4feb621

8 files changed

Lines changed: 76 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.22.2] - 2026-06-13
11+
12+
### Fixed
13+
- **`complete` survives a non-JSON enrich reply.** When the backfill model
14+
answered with prose instead of the requested JSON array — e.g. continuing the
15+
transcript's own dialogue ("Контекст в норме… Что дальше?") — the parse error
16+
aborted the whole `complete`, losing the retitle and close. Backfill is now
17+
best-effort: an unparseable chunk reply is skipped (with a warning), the parser
18+
extracts a JSON array even when wrapped in prose, and the prompt re-asserts
19+
"output ONLY the JSON array, do not continue the transcript" after the
20+
transcript. Retitle/close always run regardless of what enrich recovers.
21+
1022
## [0.22.1] - 2026-06-13
1123

1224
### Fixed

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = [
77
]
88

99
[workspace.package]
10-
version = "0.22.1"
10+
version = "0.22.2"
1111
edition = "2021"
1212
rust-version = "1.88"
1313
license = "MIT"

crates/tj-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ default = ["embed"]
2323
embed = ["tj-core/embed"]
2424

2525
[dependencies]
26-
tj-core = { package = "task-journal-core", version = "0.22.1", path = "../tj-core", default-features = false }
26+
tj-core = { package = "task-journal-core", version = "0.22.2", path = "../tj-core", default-features = false }
2727
anyhow = { workspace = true }
2828
clap = { workspace = true }
2929
tracing = { workspace = true }

crates/tj-core/src/dream/llm_backend.rs

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,16 @@ impl DreamBackend for LlmDreamBackend {
4040
};
4141
let prompt = crate::dream::prompt::build_prompt(&chunk_input);
4242
let text = self.llm.complete(&prompt, 1024)?;
43-
out.extend(parse_backfill_json(&text)?);
43+
// Backfill is best-effort: a model that replied with prose instead
44+
// of the JSON array (e.g. continued the transcript dialogue) yields
45+
// nothing for this chunk, but must NOT abort the whole finalize —
46+
// the retitle/close still need to run.
47+
match parse_backfill_json(&text) {
48+
Ok(evs) => out.extend(evs),
49+
Err(e) => {
50+
tracing::warn!(error = %e, "dream backfill: skipping unparseable chunk reply")
51+
}
52+
}
4453
}
4554
Ok(out)
4655
}
@@ -86,8 +95,12 @@ pub fn parse_backfill_json(text: &str) -> anyhow::Result<Vec<BackfillEvent>> {
8695
.trim_start_matches("```")
8796
.trim_end_matches("```")
8897
.trim();
89-
serde_json::from_str(json_str)
90-
.with_context(|| format!("dream JSON parse failed; got: {json_str}"))
98+
// Tolerate a JSON array wrapped in prose by slicing to the outer brackets.
99+
let slice = match (json_str.find('['), json_str.rfind(']')) {
100+
(Some(a), Some(b)) if b > a => &json_str[a..=b],
101+
_ => json_str,
102+
};
103+
serde_json::from_str(slice).with_context(|| format!("dream JSON parse failed; got: {json_str}"))
91104
}
92105

93106
#[cfg(test)]
@@ -111,6 +124,43 @@ mod tests {
111124
assert!(parse_backfill_json("[]").unwrap().is_empty());
112125
}
113126

127+
#[test]
128+
fn parse_extracts_array_wrapped_in_prose() {
129+
let reply = "Here are the missed events:\n[{\"event_type\":\"finding\",\
130+
\"task_id\":\"tj-1\",\"text\":\"found\",\"timestamp\":\"2026-06-13T00:00:00Z\"}]\nHope that helps!";
131+
let evs = parse_backfill_json(reply).unwrap();
132+
assert_eq!(evs.len(), 1);
133+
}
134+
135+
#[test]
136+
fn parse_errors_on_pure_prose() {
137+
// A conversational reply with no array at all must be an Err so the
138+
// backfill loop can skip the chunk instead of inventing events.
139+
assert!(parse_backfill_json("Контекст в норме. Что дальше?").is_err());
140+
}
141+
142+
#[test]
143+
fn backfill_skips_unparseable_chunk_reply() {
144+
// Model replies with prose, not JSON → backfill yields nothing but does
145+
// NOT error, so the surrounding finalize (retitle/close) still runs.
146+
struct ChattyLlm;
147+
impl LlmBackend for ChattyLlm {
148+
fn complete(&self, _prompt: &str, _max: u32) -> anyhow::Result<String> {
149+
Ok("Контекст в норме. 566.5k/1M использовано. Что дальше?".to_string())
150+
}
151+
fn name(&self) -> &'static str {
152+
"chatty"
153+
}
154+
}
155+
let b = LlmDreamBackend::new(Box::new(ChattyLlm));
156+
let input = BackfillInput {
157+
tasks: vec![],
158+
transcript: "user: hi\nassistant: hello".into(),
159+
};
160+
let evs = b.backfill(&input).unwrap();
161+
assert!(evs.is_empty());
162+
}
163+
114164
#[test]
115165
fn small_transcript_is_one_chunk() {
116166
let c = chunk_transcript("a\nb\nc\n", 100);

crates/tj-core/src/dream/prompt.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ pub fn build_prompt(input: &BackfillInput) -> String {
3434
- Respond with ONLY a JSON array of objects: \
3535
{{\"event_type\",\"task_id\",\"text\",\"timestamp\"}}. Empty array if nothing missed.\n\n\
3636
# Candidate tasks and their existing events\n{tasks}\n\
37-
# Transcript\n{transcript}\n",
37+
# Transcript\n{transcript}\n\n\
38+
Remember: output ONLY the JSON array of missed events described above. \
39+
Do NOT reply to, summarise, or continue the transcript; if nothing was \
40+
missed, output [].\n",
3841
types = ALLOWED_TYPES,
3942
tasks = tasks_block,
4043
transcript = input.transcript,

crates/tj-mcp/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ path = "src/main.rs"
1717

1818
[dependencies]
1919
# Lean: the MCP server doesn't embed yet, so it skips the model2vec backend.
20-
tj-core = { package = "task-journal-core", version = "0.22.1", path = "../tj-core", default-features = false }
20+
tj-core = { package = "task-journal-core", version = "0.22.2", path = "../tj-core", default-features = false }
2121
anyhow = { workspace = true }
2222
tokio = { workspace = true }
2323
tracing = { workspace = true }

plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "task-journal",
3-
"version": "0.22.1",
3+
"version": "0.22.2",
44
"description": "Append-only journal of AI-coding task reasoning chains: hypotheses, decisions, rejections, evidence. Renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
55
"author": {
66
"name": "Mher Shahinyan"

0 commit comments

Comments
 (0)