fix(websocket): narrow compact replay detection
This commit is contained in:
@@ -703,29 +703,20 @@ func mergeJSONArrayRaw(existingRaw, appendRaw string) (string, error) {
|
|||||||
return string(out), nil
|
return string(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// inputContainsFullTranscript returns true when the input array looks like a
|
// inputContainsFullTranscript returns true when the input array carries compact
|
||||||
// complete conversation history rather than an incremental append. After a
|
// replay markers that indicate the client already sent the full conversation
|
||||||
// client-side compact the input already carries the full (compacted) transcript
|
// transcript. Merging that input with stale lastRequest/lastResponseOutput
|
||||||
// which may include assistant messages or compaction items. Merging that with
|
// would duplicate or break function_call/function_call_output pairings, so the
|
||||||
// the stale lastRequest / lastResponseOutput would duplicate or break
|
// caller should use the input as-is.
|
||||||
// function_call / function_call_output pairings, so the caller should use the
|
|
||||||
// input as-is.
|
|
||||||
//
|
//
|
||||||
// Heuristic: the array is a full transcript when it contains either
|
// Assistant messages alone are not enough to classify the payload as a replay:
|
||||||
// - a message with role="assistant", or
|
// incremental websocket requests may legitimately append assistant items.
|
||||||
// - a compaction item (type="compaction" or "compaction_summary").
|
|
||||||
//
|
|
||||||
// Normal incremental turns only contain user messages or function_call_output
|
|
||||||
// items and never carry either of these signals.
|
|
||||||
func inputContainsFullTranscript(input gjson.Result) bool {
|
func inputContainsFullTranscript(input gjson.Result) bool {
|
||||||
if !input.IsArray() {
|
if !input.IsArray() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, item := range input.Array() {
|
for _, item := range input.Array() {
|
||||||
t := item.Get("type").String()
|
t := item.Get("type").String()
|
||||||
if t == "message" && item.Get("role").String() == "assistant" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if t == "compaction" || t == "compaction_summary" {
|
if t == "compaction" || t == "compaction_summary" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1401,13 +1401,13 @@ func TestResponsesWebsocketCompactionResetsTurnStateOnTranscriptReplacement(t *t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInputContainsFullTranscriptDetectsAssistantMessage(t *testing.T) {
|
func TestInputContainsFullTranscriptFalseForAssistantMessageOnly(t *testing.T) {
|
||||||
input := gjson.Parse(`[
|
input := gjson.Parse(`[
|
||||||
{"type":"message","role":"user","content":"hello"},
|
{"type":"message","role":"user","content":"hello"},
|
||||||
{"type":"message","role":"assistant","content":"hi there"}
|
{"type":"message","role":"assistant","content":"hi there"}
|
||||||
]`)
|
]`)
|
||||||
if !inputContainsFullTranscript(input) {
|
if inputContainsFullTranscript(input) {
|
||||||
t.Fatal("expected full transcript when assistant message is present")
|
t.Fatal("assistant message alone must not be treated as full transcript")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1501,3 +1501,33 @@ func TestNormalizeSubsequentRequestIncrementalInputStillMerges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeSubsequentRequestAssistantIncrementalInputStillMerges(t *testing.T) {
|
||||||
|
lastRequest := []byte(`{"model":"gpt-5.4","stream":true,"input":[
|
||||||
|
{"type":"message","role":"user","id":"msg-1","content":"hello"}
|
||||||
|
]}`)
|
||||||
|
lastResponseOutput := []byte(`[
|
||||||
|
{"type":"message","role":"assistant","id":"msg-2","content":"prior assistant"},
|
||||||
|
{"type":"function_call","id":"fc-1","call_id":"call-1","name":"bash","arguments":"{}"}
|
||||||
|
]`)
|
||||||
|
raw := []byte(`{"type":"response.append","input":[
|
||||||
|
{"type":"message","role":"assistant","id":"msg-3","content":"patched assistant turn"}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
normalized, _, errMsg := normalizeResponsesWebsocketRequest(raw, lastRequest, lastResponseOutput)
|
||||||
|
if errMsg != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", errMsg.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
input := gjson.GetBytes(normalized, "input").Array()
|
||||||
|
if len(input) != 4 {
|
||||||
|
t.Fatalf("input len = %d, want 4 (merged)", len(input))
|
||||||
|
}
|
||||||
|
wantIDs := []string{"msg-1", "msg-2", "fc-1", "msg-3"}
|
||||||
|
for i, want := range wantIDs {
|
||||||
|
got := input[i].Get("id").String()
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("input[%d].id = %q, want %q", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user