From 1f1be982c95e2df7a1c062586efbb7ddaf0c95af Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 14:29:11 +0900 Subject: [PATCH] =?UTF-8?q?fix(openai):=20artifact=20selector=20=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=BC=EC=9D=84=20=EB=B6=84=EB=A5=98=20=EC=A0=84=20?= =?UTF-8?q?=EB=B2=84=ED=8D=BC=EB=A7=81=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/edge/internal/openai/anthropic_stream.go | 7 +++- .../openai/hot_path_anthropic_gate_test.go | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/edge/internal/openai/anthropic_stream.go b/apps/edge/internal/openai/anthropic_stream.go index 5d4153bc..2e0dee74 100644 --- a/apps/edge/internal/openai/anthropic_stream.go +++ b/apps/edge/internal/openai/anthropic_stream.go @@ -603,7 +603,10 @@ func (c *anthropicHotPathCodec) runInitialPresetTurn( gate hotPathSelectorGate err error ) - if c.stream { + selectorState := s.artifactFrontiers.selectorInstructionState( + runMeta["iop_logical_request_id"], s.edgeIDValue(), + ) + if c.stream && selectorState == selectorInstructionNone { outer := c.callerOuterTurn("", hotPathOutputTokenCap(runMeta)) if err := c.prepareProgressiveWriter(w, outer, false); err != nil { return stage, false, err @@ -612,6 +615,8 @@ func (c *anthropicHotPathCodec) runInitialPresetTurn( r.Context(), dispatch, "anthropic", runMeta["iop_stage_id"], result, outer, ) } else { + // Artifact selector calls are private IOP control data. Keep them buffered + // until classification expands them into caller-visible workspace writes. stage, gate, err = s.collectPresetSelectorResult(r.Context(), dispatch, "anthropic", result) } if err != nil { diff --git a/apps/edge/internal/openai/hot_path_anthropic_gate_test.go b/apps/edge/internal/openai/hot_path_anthropic_gate_test.go index 78cf3abc..1ce58c79 100644 --- a/apps/edge/internal/openai/hot_path_anthropic_gate_test.go +++ b/apps/edge/internal/openai/hot_path_anthropic_gate_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "iop/packages/go/config" iop "iop/proto/gen/iop" ) @@ -150,6 +151,40 @@ func TestHotPathAnthropicDirectStreamPreservesEmptyToolInput(t *testing.T) { assertHotPathWaiting(t, srv, "msg-empty-tool-tool-1", "provider-zero-arg-tool") } +func TestHotPathAnthropicArtifactSelectorBuffersPrivateCalls(t *testing.T) { + fixture := newScriptedLightFixture(t, "anthropic", false) + alternative := scriptedLightWorkspaceAlternative() + write := alternative.Operations["write"] + write.CreatesParents = true + alternative.Operations["write"] = write + preset := hotPathSelectorPreset([]string{config.ModeDirect, config.ModeLight}) + preset.WorkspaceTools = []config.ExecutionWorkspaceToolAlternative{alternative} + fixture.server.SetExecutionPresets([]config.ExecutionPreset{preset}) + fixture.service.responses[0] = func(requestID string) string { + return scriptedArtifactPair("anthropic", requestID) + } + + response := fixture.requestWithOptions(64, true) + if response.Code != http.StatusOK || strings.Contains(response.Body.String(), `"type":"error"`) || + strings.Contains(response.Body.String(), hotPathArtifactPairToolName) { + t.Fatalf("artifact selector leaked or failed: status=%d body=%s", response.Code, response.Body.String()) + } + events := decodeHotPathAnthropicSSE(t, response.Body.String()) + var writeCalls int + for _, event := range events { + if event.name != "content_block_start" { + continue + } + block := hotPathAnthropicMap(t, event.payload["content_block"]) + if block["type"] == "tool_use" && block["name"] == "write_file" { + writeCalls++ + } + } + if writeCalls != 2 { + t.Fatalf("caller-visible write calls=%d, want 2; body=%s", writeCalls, response.Body.String()) + } +} + func TestHotPathAnthropicLightStreamAggregatesStages(t *testing.T) { fixture := newScriptedLightFixture(t, "anthropic", false) fixture.service.responses[4] = func(requestID string) string {