fix: Kiro Opus streaming response interruption (fixes #65)

Improve AWS Event Stream parsing to fix streaming response interruption:

1. Change JSON start detection to search for specific patterns instead of
   simple '{' character. AWS Event Stream contains binary headers that may
   have '{' in unexpected positions. Now searches for valid JSON patterns:
   - {"content": - text content
   - {"name": - tool call start
   - {"toolUseId": - tool use event
   - {"input": - tool input continuation
   - {"stop": - stop event
   - {"usage": - usage info
   - {"contextUsagePercentage": - context usage

2. Handle standalone {"input":...} events for tool call input continuation.
   Kiro may send tool calls in multiple events:
   - {"name":"xxx","toolUseId":"xxx"} - start
   - {"input":"..."} - input data (may be multiple)
   - {"stop":true} - end

3. Handle standalone {"stop":true} events that close active tool calls.

This mirrors the fix from AIClient-2-API project.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
coso
2026-01-08 19:48:24 +08:00
co-authored by Claude Opus 4.5
parent f0b8c74a98
commit c034c97be1
4 changed files with 90 additions and 7 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.36.1",
"version": "0.36.2",
"type": "module",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.36.1"
version = "0.36.2"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -276,11 +276,57 @@ impl AwsEventStreamParser {
}
/// 查找 JSON 对象的开始位置
///
/// AWS Event Stream 包含二进制头部,简单的 `{` 可能匹配到错误位置。
/// 我们只搜索有效的 JSON 模式:
/// - `{"content":` - 文本内容
/// - `{"name":` - 工具调用开始(包含 toolUseId)
/// - `{"toolUseId":` - 工具调用事件
/// - `{"input":` - 工具参数续传
/// - `{"stop":` - 停止事件
/// - `{"usage":` - 使用量
/// - `{"contextUsagePercentage":` - 上下文使用百分比
/// - `{"followupPrompt":` - 后续提示(跳过)
fn find_json_start(&self, from: usize) -> Option<usize> {
self.buffer[from..]
.iter()
.position(|&b| b == b'{')
.map(|p| from + p)
let buffer = &self.buffer[from..];
// 定义所有有效的 JSON 模式
let patterns: &[&[u8]] = &[
b"{\"content\":",
b"{\"name\":",
b"{\"toolUseId\":",
b"{\"input\":",
b"{\"stop\":",
b"{\"usage\":",
b"{\"contextUsagePercentage\":",
b"{\"followupPrompt\":",
];
// 查找所有模式的位置,返回最早出现的
let mut earliest: Option<usize> = None;
for pattern in patterns {
if let Some(pos) = Self::find_pattern(buffer, pattern) {
match earliest {
None => earliest = Some(pos),
Some(e) if pos < e => earliest = Some(pos),
_ => {}
}
}
}
earliest.map(|p| from + p)
}
/// 在缓冲区中查找模式
fn find_pattern(buffer: &[u8], pattern: &[u8]) -> Option<usize> {
if pattern.is_empty() || buffer.len() < pattern.len() {
return None;
}
buffer
.windows(pattern.len())
.position(|window| window == pattern)
}
/// 从缓冲区中提取完整的 JSON 对象
@@ -439,8 +485,45 @@ impl AwsEventStreamParser {
}
}
}
// 处理独立的 input 事件(工具调用的 input 续传,没有 toolUseId)
// Kiro 的工具调用可能分多个事件发送:
// 1. {"name":"xxx","toolUseId":"xxx"} - 开始
// 2. {"input":"..."} - input 数据(可能多次)
// 3. {"stop":true} - 结束
else if let Some(input_chunk) = value.get("input").and_then(|v| v.as_str()) {
// 找到当前活跃的工具调用(应该只有一个)
if let Some((tool_id, accumulator)) = self.tool_accumulators.iter_mut().next() {
let tool_id = tool_id.clone();
accumulator.input.push_str(input_chunk);
events.push(StreamEvent::ToolUseInputDelta {
id: tool_id,
partial_json: input_chunk.to_string(),
});
} else {
tracing::warn!(
"[AWS_PARSER] 收到独立的 input 事件但没有活跃的工具调用: {}",
input_chunk
);
}
}
// 处理独立的 stop 事件
else if value.get("stop").and_then(|v| v.as_bool()).unwrap_or(false) {
// 首先检查是否有活跃的工具调用需要关闭
// 这处理 Kiro 发送 {"stop":true} 来结束工具调用的情况
if !self.tool_accumulators.is_empty() {
// 关闭所有活跃的工具调用
let tool_ids: Vec<String> = self.tool_accumulators.keys().cloned().collect();
for tool_id in tool_ids {
if let Some(acc) = self.tool_accumulators.remove(&tool_id) {
self.context.remove_tool_call(&tool_id);
events.push(StreamEvent::ToolUseStop { id: tool_id });
events.push(StreamEvent::ContentBlockStop {
index: acc.block_index,
});
}
}
}
// 关闭文本块(如果有)
if let Some(index) = self.text_block_index.take() {
self.in_text_block = false;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.36.0",
"version": "0.36.2",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",