Skip to content

Commit 3d5645a

Browse files
author
gluck
committed
copilot: an empty assistant message never enters the translation cache
A model that answers entirely through its tools ends the turn with an empty assistant message, and the proxy reports it as a native item whose single output_text carries "". The responses dialect trusted the authoritative terminal list and cached that item verbatim. Replayed as input on the next turn, it is refused: provider_error in under a second, one event, no explanation, and the item is in the cache, so the aria can never speak again. Aria 0c1faa2f went mute that way with four unanswered messages behind it, and the only rescue was a fork taken before the offending node. The anthropic dialect has guarded this for a while (cacheableNativeBlock drops a text block with no text, next to a comment about bricking an aria). This brings the same rule to the native path here: strip the text-free parts of a message item, drop the item when nothing is left, and leave reasoning items alone, since their content is legitimately empty and the payload rides in encrypted_content. Parts of a shape this file does not recognize are kept, not guessed at. The fingerprint goes to v5 so the arias already holding one are cured rather than merely spared: a stale fingerprint clears the translation log, and re-encoding from the fig IR drops the empty output node instead of replaying it. The price is one uncached prompt per aria.
1 parent f5f9eea commit 3d5645a

3 files changed

Lines changed: 190 additions & 3 deletions

File tree

‎internal/provider/copilot/responses.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ import (
2525

2626
// v4: a tool round with nobody speaking carries its reminders inside the last
2727
// function_call_output instead of a user message of their own.
28-
const responsesFingerprintPrefix = "copilot-responses/v4"
28+
//
29+
// v5: text-free assistant message items are no longer cached. The bump is the
30+
// cure for the arias that already hold one: a stale fingerprint clears the
31+
// log, and re-encoding from the fig IR drops the item instead of replaying it
32+
// into a payload the proxy refuses. The price is one uncached prompt per aria.
33+
const responsesFingerprintPrefix = "copilot-responses/v5"
2934

3035
type responseTokenSource interface {
3136
Resolve() (string, error)

‎internal/provider/copilot/responses_stream.go‎

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,9 @@ func (p *responsePartial) outputItems() []json.RawMessage {
159159
continue
160160
}
161161
if part.raw != nil {
162-
out = append(out, part.raw)
162+
if vetted, ok := vetResponseMessageItem(part.raw); ok {
163+
out = append(out, vetted)
164+
}
163165
continue
164166
}
165167
var item any
@@ -422,7 +424,12 @@ func (s *responseStreamState) reconcile(response *responseObject) error {
422424
return fmt.Errorf("copilot responses: decode terminal item: %w", err)
423425
}
424426
if item.Type != "function_call" {
425-
out = append(out, raw)
427+
// The authoritative terminal list is trusted for everything
428+
// except a message with nothing in it: that one item, replayed
429+
// as input, gets the whole request refused.
430+
if vetted, ok := vetResponseMessageItem(raw); ok {
431+
out = append(out, vetted)
432+
}
426433
continue
427434
}
428435
if item.CallID == "" || item.Name == "" {
@@ -601,6 +608,78 @@ func safeResponseReason(reason string) string {
601608
}
602609
}
603610

611+
// vetResponseMessageItem drops the text-free parts of a native assistant
612+
// message, and the whole item when nothing is left.
613+
//
614+
// A model that says everything through its tools ends the turn with an empty
615+
// assistant message, and the proxy sends it as a native item with one
616+
// output_text whose text is "". Replayed as input, that item is refused:
617+
// provider_error inside a second, no stream, no explanation. The item is in
618+
// the translation cache by then, so every later turn carries it and the aria
619+
// cannot speak again. Reconstructed items were already filtered this way; this
620+
// closes the native path on the same rule.
621+
//
622+
// Reasoning items are not touched: their content is legitimately empty, the
623+
// payload rides in encrypted_content.
624+
func vetResponseMessageItem(raw json.RawMessage) (json.RawMessage, bool) {
625+
var item struct {
626+
Type string `json:"type"`
627+
Content []json.RawMessage `json:"content"`
628+
}
629+
if json.Unmarshal(raw, &item) != nil || item.Type != "message" {
630+
return raw, true
631+
}
632+
kept := make([]json.RawMessage, 0, len(item.Content))
633+
for _, part := range item.Content {
634+
if responsePartIsBlank(part) {
635+
continue
636+
}
637+
kept = append(kept, part)
638+
}
639+
if len(kept) == 0 {
640+
return nil, false
641+
}
642+
if len(kept) == len(item.Content) {
643+
return raw, true
644+
}
645+
// Rebuild from the ORIGINAL part bytes: annotations, logprobs and any
646+
// field this file has never heard of survive a partial prune.
647+
var fields map[string]json.RawMessage
648+
if json.Unmarshal(raw, &fields) != nil {
649+
return raw, true
650+
}
651+
content, err := json.Marshal(kept)
652+
if err != nil {
653+
return raw, true
654+
}
655+
fields["content"] = content
656+
out, err := json.Marshal(fields)
657+
if err != nil {
658+
return raw, true
659+
}
660+
return out, true
661+
}
662+
663+
// responsePartIsBlank reports a message part that carries nothing a reader
664+
// could see. A part of an unknown shape is NOT blank: this prunes what it
665+
// recognizes and leaves everything else alone.
666+
func responsePartIsBlank(raw json.RawMessage) bool {
667+
var part struct {
668+
Type string `json:"type"`
669+
Text string `json:"text"`
670+
ImageURL string `json:"image_url"`
671+
}
672+
if json.Unmarshal(raw, &part) != nil {
673+
return false
674+
}
675+
switch part.Type {
676+
case "output_text", "text", "summary_text", "input_text", "refusal":
677+
return strings.TrimSpace(part.Text) == "" && part.ImageURL == ""
678+
default:
679+
return false
680+
}
681+
}
682+
604683
// Keep the native item's other fields verbatim, but give a rejected call a
605684
// valid arguments object for replay. The sentinel retains the original bytes;
606685
// the agent refuses execution and sends INVALID_JSON on the original call_id.

‎internal/provider/copilot/responses_stream_test.go‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,106 @@ func TestResponsesAbnormalTerminalPreservesAuthoritativeNativeOutput(t *testing.
257257
require.Len(t, out.Output, 1)
258258
require.Equal(t, string(native), string(out.Output[0]))
259259
}
260+
261+
// A model that answers through its tools and then says nothing ends the turn
262+
// with an empty assistant message. Cached, it is replayed on every later turn,
263+
// and the proxy refuses the whole request: the aria goes mute for good. The
264+
// item must never reach the cache.
265+
func TestResponsesTextFreeAssistantItemNeverEntersTheCache(t *testing.T) {
266+
reasoning := json.RawMessage(`{"type":"reasoning","id":"signed","encrypted_content":"opaque","content":[],"summary":[]}`)
267+
empty := json.RawMessage(`{"type":"message","role":"assistant","id":"blank","content":[{"type":"output_text","text":"","annotations":[],"logprobs":[]}]}`)
268+
call := json.RawMessage(`{"type":"function_call","call_id":"c1","name":"echo","arguments":"{}","status":"completed"}`)
269+
server := newResponseServer(t, func(conn *websocket.Conn) {
270+
defer conn.Close()
271+
var req responseCreateRequest
272+
if websocket.JSON.Receive(conn, &req) != nil {
273+
return
274+
}
275+
_ = websocket.JSON.Send(conn, map[string]any{"type": "response.completed", "response": map[string]any{
276+
"status": "completed", "output": []any{reasoning, empty, call},
277+
}})
278+
})
279+
cache := store.NewMemLog[[]json.RawMessage]()
280+
p := newResponsesTestProvider(server, cache)
281+
bus := &responseTestBus{}
282+
require.NoError(t, p.Send(context.Background(), provider.SendInput{AriaID: "blank", FigLog: newResponsesInputLog(t)}, bus))
283+
284+
require.Len(t, bus.cache, 1)
285+
payload := bus.cache[0].Payload
286+
require.Len(t, payload, 2, "the empty message is gone, reasoning and the call are not")
287+
require.JSONEq(t, string(reasoning), string(payload[0]))
288+
require.Contains(t, string(payload[1]), `"function_call"`)
289+
for _, raw := range payload {
290+
require.NotContains(t, string(raw), `"blank"`, "no trace of the text-free item")
291+
}
292+
}
293+
294+
func TestVetResponseMessageItemPrunesOnlyWhatItRecognizes(t *testing.T) {
295+
for _, tc := range []struct {
296+
name, raw, want string
297+
keep bool
298+
}{
299+
{
300+
name: "text-free message is dropped whole",
301+
raw: `{"type":"message","role":"assistant","content":[{"type":"output_text","text":""}]}`,
302+
},
303+
{
304+
name: "whitespace is not speech",
305+
raw: `{"type":"message","role":"assistant","content":[{"type":"output_text","text":" \n "}]}`,
306+
},
307+
{
308+
name: "a message with no content at all is dropped",
309+
raw: `{"type":"message","role":"assistant","content":[]}`,
310+
},
311+
{
312+
name: "text survives verbatim",
313+
raw: `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ecco","annotations":[]}]}`,
314+
want: `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ecco","annotations":[]}]}`,
315+
keep: true,
316+
},
317+
{
318+
name: "a mixed message keeps the spoken part with its fields",
319+
raw: `{"type":"message","role":"assistant","id":"m","content":[{"type":"output_text","text":""},{"type":"output_text","text":"ecco","annotations":["a"]}]}`,
320+
want: `{"type":"message","role":"assistant","id":"m","content":[{"type":"output_text","text":"ecco","annotations":["a"]}]}`,
321+
keep: true,
322+
},
323+
{
324+
name: "reasoning keeps its empty content: the payload is encrypted",
325+
raw: `{"type":"reasoning","encrypted_content":"opaque","content":[],"summary":[]}`,
326+
want: `{"type":"reasoning","encrypted_content":"opaque","content":[],"summary":[]}`,
327+
keep: true,
328+
},
329+
{
330+
name: "a part shape this file does not know is left alone",
331+
raw: `{"type":"message","role":"assistant","content":[{"type":"output_audio","text":""}]}`,
332+
want: `{"type":"message","role":"assistant","content":[{"type":"output_audio","text":""}]}`,
333+
keep: true,
334+
},
335+
{
336+
name: "an image part is speech even with no text",
337+
raw: `{"type":"message","role":"assistant","content":[{"type":"input_text","text":"","image_url":"data:image/png;base64,AA"}]}`,
338+
want: `{"type":"message","role":"assistant","content":[{"type":"input_text","text":"","image_url":"data:image/png;base64,AA"}]}`,
339+
keep: true,
340+
},
341+
{
342+
name: "undecodable bytes pass through untouched",
343+
raw: `not json`,
344+
want: `not json`,
345+
keep: true,
346+
},
347+
} {
348+
t.Run(tc.name, func(t *testing.T) {
349+
got, ok := vetResponseMessageItem(json.RawMessage(tc.raw))
350+
require.Equal(t, tc.keep, ok)
351+
if !tc.keep {
352+
require.Nil(t, got)
353+
return
354+
}
355+
if tc.want == "not json" {
356+
require.Equal(t, tc.want, string(got))
357+
return
358+
}
359+
require.JSONEq(t, tc.want, string(got))
360+
})
361+
}
362+
}

0 commit comments

Comments
 (0)