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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,34 @@ import XCTest
@testable import T3VoiceLogic

final class ReasoningTextTests: XCTestCase {
private let thinkingTemplate = "{% if enable_thinking %}<think>{% endif %}"

func testDisablesThinkingForTheLegacyChatMLGenerationPrefix() {
XCTAssertEqual(
ReasoningText.nonThinkingPrompt("<|im_start|>assistant\n", template: thinkingTemplate),
"<|im_start|>assistant\n<think>\n\n</think>\n\n"
)
}

func testClosesAnAlreadyOpenedThinkingPrefix() {
XCTAssertEqual(
ReasoningText.nonThinkingPrompt(
"<|im_start|>assistant\n<think>\n", template: thinkingTemplate),
"<|im_start|>assistant\n<think>\n\n</think>\n\n"
)
}

func testLeavesAnAlreadyDisabledThinkingPrefixAlone() {
let prompt = "<|im_start|>assistant\n<think>\n\n</think>\n\n"
XCTAssertEqual(ReasoningText.nonThinkingPrompt(prompt, template: thinkingTemplate), prompt)
}

func testDoesNotAddThinkingTokensToOtherModels() {
let prompt = "<|im_start|>assistant\n"
XCTAssertEqual(ReasoningText.nonThinkingPrompt(prompt, template: nil), prompt)
XCTAssertEqual(ReasoningText.nonThinkingPrompt(prompt, template: "chatml"), prompt)
}

func testRemovesAThinkingBlockFromTheRewrite() {
XCTAssertEqual(
ReasoningText.strip("<think>The user said ghosty.</think>Open the Ghostty window."),
Expand Down
26 changes: 22 additions & 4 deletions apps/mobile/modules/t3-voice/ios/LlamaCleanupSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,10 @@ final class LlamaCleanupSession {
) throws -> Rewrite {
llama_memory_clear(llama_get_memory(context), true)

let prompt = applyChatTemplate(systemPrompt: systemPrompt, transcript: transcript)
let prompt = ReasoningText.nonThinkingPrompt(
applyChatTemplate(systemPrompt: systemPrompt, transcript: transcript),
template: chatTemplate
)
var tokens = try tokenize(prompt, addSpecial: chatTemplate == nil)

// Leave room for the answer. A prompt that fills the window produces a
Expand All @@ -138,15 +141,24 @@ final class LlamaCleanupSession {
var output: [UInt8] = []
var generated = 0
var isComplete = false
var stopReason = "token-limit"

while generated < maximumOutputTokens {
if shouldStop() || Date() >= deadline { break }
if shouldStop() {
stopReason = "cancelled"
break
}
if Date() >= deadline {
stopReason = "timeout"
break
}

var token = llama_sampler_sample(sampler, context, -1)
// The model's own end of turn is the only ending that means the rewrite
// covers the whole transcript.
if llama_vocab_is_eog(vocab, token) {
isComplete = true
stopReason = "end-of-turn"
break
}

Expand All @@ -158,7 +170,12 @@ final class LlamaCleanupSession {
}
}

return Rewrite(text: ReasoningText.strip(Self.decodeUTF8(output)), isComplete: isComplete)
let text = ReasoningText.strip(Self.decodeUTF8(output))
VoiceDiagnostics.report(
"cleanup",
"generation stop=\(stopReason) tokens=\(generated) bytes=\(output.count) characters=\(text.count)"
)
return Rewrite(text: text, isComplete: isComplete)
}

private func applyChatTemplate(systemPrompt: String, transcript: String) -> String {
Expand All @@ -176,7 +193,8 @@ final class LlamaCleanupSession {
for message in messages { free(UnsafeMutablePointer(mutating: message.role)) }
}

var buffer = [CChar](repeating: 0, count: (systemPrompt.utf8.count + transcript.utf8.count) * 2 + 1024)
var buffer = [CChar](
repeating: 0, count: (systemPrompt.utf8.count + transcript.utf8.count) * 2 + 1024)
let written = llama_chat_apply_template(
chatTemplate,
&messages,
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/modules/t3-voice/ios/ReasoningText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@ import Foundation

/// Cleanup output as the model actually emits it.
enum ReasoningText {
/// Matches the model template's `enable_thinking=false` generation prefix.
/// The legacy llama.cpp template API does not evaluate that Jinja option.
/// Without the closed block, larger Qwen models can use the whole cleanup
/// budget on reasoning and never emit the transcript.
static func nonThinkingPrompt(_ prompt: String, template: String?) -> String {
guard let template, template.contains("enable_thinking"), template.contains("<think>") else {
return prompt
}

let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasSuffix("</think>") { return prompt }
if trimmed.hasSuffix("<think>") { return trimmed + "\n\n</think>\n\n" }
return prompt + "<think>\n\n</think>\n\n"
}

/// Removes the reasoning block a thinking model writes before its answer.
///
/// Qwen emits `<think>...</think>` ahead of the rewrite. The tokens are not
Expand Down
10 changes: 10 additions & 0 deletions packages/client-runtime/src/voice-input/cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ describe("resolveCleanupOutcome", () => {
});
});

it("reports unfinished generation even when no visible answer was produced", () => {
expect(
resolveCleanupOutcome("Keep the entire transcript.", { text: "", complete: false }),
).toEqual({
kind: "raw",
text: "Keep the entire transcript.",
reason: "incomplete",
});
});

it("degrades when the model answered the transcript instead of rewriting it", () => {
const raw = "what is the capital of france";
const answered =
Expand Down
8 changes: 4 additions & 4 deletions packages/client-runtime/src/voice-input/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,14 @@ export function resolveCleanupOutcome(raw: string, cleaned: VoiceCleanupResult):
const trimmedRaw = raw.trim();
const trimmedCleaned = cleaned.text.trim();

if (trimmedCleaned.length === 0) {
return { kind: "raw", text: trimmedRaw, reason: "empty" };
}

if (!cleaned.complete) {
return { kind: "raw", text: trimmedRaw, reason: "incomplete" };
}

if (trimmedCleaned.length === 0) {
return { kind: "raw", text: trimmedRaw, reason: "empty" };
}

if (trimmedRaw.length >= CLEANUP_RATIO_MINIMUM_LENGTH) {
const ratio = trimmedCleaned.length / trimmedRaw.length;
if (ratio < CLEANUP_MINIMUM_RATIO || ratio > CLEANUP_MAXIMUM_RATIO) {
Expand Down
Loading