mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c169b6119e | ||
|
|
a555b584fe |
No files matched your search
@@ -59,7 +59,6 @@ const (
|
||||
responsesEventOutputItemAdded = "response.output_item.added"
|
||||
responsesEventContentPartAdded = "response.content_part.added"
|
||||
responsesEventOutputTextDelta = "response.output_text.delta"
|
||||
responsesEventReasoningDelta = "response.reasoning_summary_text.delta"
|
||||
responsesEventOutputTextDone = "response.output_text.done"
|
||||
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
|
||||
responsesEventContentPartDone = "response.content_part.done"
|
||||
@@ -87,10 +86,6 @@ type responsesRequest struct {
|
||||
// here; omitted entirely when the caller requests no (or an unsupported) effort.
|
||||
type responsesReasoning struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
// Summary requests a streamed reasoning summary ("auto" lets the API pick a
|
||||
// level). Without it the backend emits no reasoning events, so a long thinking
|
||||
// phase produces zero visible output and reads as a hang in the UI.
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// inputItem is one element of the Responses `input` array. The Type field
|
||||
@@ -261,9 +256,7 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
|
||||
// and an empty or unsupported effort simply omits the field — without this the
|
||||
// caller's chosen effort was silently dropped for every Codex model.
|
||||
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
|
||||
// Summary "auto" makes the backend stream reasoning_summary_text deltas so a
|
||||
// long thinking phase shows live progress instead of looking hung.
|
||||
req.Reasoning = &responsesReasoning{Effort: effort, Summary: "auto"}
|
||||
req.Reasoning = &responsesReasoning{Effort: effort}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
@@ -508,17 +501,6 @@ func (p *CodexProvider) emitResponsesEvent(
|
||||
})
|
||||
}
|
||||
return true
|
||||
case responsesEventReasoningDelta:
|
||||
// Reasoning summary deltas: surface as live "thinking" so a long reasoning
|
||||
// phase shows progress (and keeps the activity clock fresh) instead of
|
||||
// looking like a hang. Requested via reasoning.summary="auto".
|
||||
if event.Delta != "" {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventReasoning,
|
||||
Content: event.Delta,
|
||||
})
|
||||
}
|
||||
return true
|
||||
case responsesEventFunctionArgsDelta:
|
||||
p.handleFunctionArgsDelta(ctx, &event, state, events)
|
||||
return true
|
||||
|
||||
@@ -485,58 +485,6 @@ func TestCodexProviderForwardsReasoningEffort(t *testing.T) {
|
||||
if reasoning["effort"] != "high" {
|
||||
t.Fatalf("body.reasoning.effort = %#v, want high", reasoning["effort"])
|
||||
}
|
||||
// A reasoning summary must be requested so the backend streams
|
||||
// reasoning_summary_text deltas — otherwise a long think shows no live output.
|
||||
if reasoning["summary"] != "auto" {
|
||||
t.Fatalf("body.reasoning.summary = %#v, want auto", reasoning["summary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderStreamsReasoningSummaryDeltas(t *testing.T) {
|
||||
// reasoning_summary_text deltas must surface as StreamEventReasoning (live
|
||||
// "thinking"), in order, alongside the normal text output. Without this a long
|
||||
// reasoning phase produces zero visible output and reads as a hang.
|
||||
var rec codexRequest
|
||||
srv := newCodexResponsesServer(t, &rec,
|
||||
`{"type":"response.created","response":{"id":"resp-1","status":"in_progress"}}`,
|
||||
`{"type":"response.reasoning_summary_text.delta","delta":"Thinking. "}`,
|
||||
`{"type":"response.reasoning_summary_text.delta","delta":"Planning."}`,
|
||||
`{"type":"response.output_text.delta","delta":"done"}`,
|
||||
`{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`,
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
provider, err := NewCodexProvider(CodexOptions{
|
||||
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5"},
|
||||
AccountID: "acc-x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodexProvider: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "solve it"}},
|
||||
ReasoningEffort: "high",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion: %v", err)
|
||||
}
|
||||
var reasoning, text []string
|
||||
for ev := range stream {
|
||||
switch ev.Type {
|
||||
case zeroruntime.StreamEventReasoning:
|
||||
reasoning = append(reasoning, ev.Content)
|
||||
case zeroruntime.StreamEventText:
|
||||
text = append(text, ev.Content)
|
||||
case zeroruntime.StreamEventError:
|
||||
t.Fatalf("unexpected error event: %q", ev.Error)
|
||||
}
|
||||
}
|
||||
if got := strings.Join(reasoning, ""); got != "Thinking. Planning." {
|
||||
t.Fatalf("reasoning deltas = %q, want %q", got, "Thinking. Planning.")
|
||||
}
|
||||
if got := strings.Join(text, ""); got != "done" {
|
||||
t.Fatalf("text deltas = %q, want %q", got, "done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderOmitsReasoningWhenUnset(t *testing.T) {
|
||||
|
||||
+21
-2
@@ -103,6 +103,25 @@ func errResult(format string, a ...any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusError, Output: "Error: " + fmt.Sprintf(format, a...)}
|
||||
}
|
||||
|
||||
// agentTypeProperty builds the schema for an agent-type parameter, constraining it
|
||||
// to the roster's currently registered types (the built-ins plus any user-registered
|
||||
// agents) via an enum so a model can't spawn or hand off to an unknown type — the
|
||||
// schema teaches the valid set up front instead of the model learning it from a
|
||||
// failed call. Falls back to a free-form string if the roster is unavailable or
|
||||
// empty (so the tool never advertises an empty, un-satisfiable enum).
|
||||
func agentTypeProperty(sw *Swarm, description string) tools.PropertySchema {
|
||||
prop := tools.PropertySchema{Type: "string", Description: description}
|
||||
if sw == nil {
|
||||
return prop
|
||||
}
|
||||
if registry := sw.Registry(); registry != nil {
|
||||
if types := registry.AgentTypes(); len(types) > 0 {
|
||||
prop.Enum = types
|
||||
}
|
||||
}
|
||||
return prop
|
||||
}
|
||||
|
||||
// ---- swarm_spawn -----------------------------------------------------------
|
||||
|
||||
type spawnTool struct {
|
||||
@@ -118,7 +137,7 @@ func (t *spawnTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"agent_type": {Type: "string", Description: "Roster agent type to spawn (e.g. teammate, subagent)."},
|
||||
"agent_type": agentTypeProperty(t.sw, "Roster agent type to spawn."),
|
||||
"task": {Type: "string", Description: "The focused task/briefing for the member."},
|
||||
"team": {Type: "string", Description: "Team to spawn into. Defaults to \"default\"."},
|
||||
},
|
||||
@@ -353,7 +372,7 @@ func (t *handoffTool) Parameters() tools.Schema {
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"task_id": {Type: "string", Description: "Task id to hand off."},
|
||||
"to_agent_type": {Type: "string", Description: "Roster agent type to take over."},
|
||||
"to_agent_type": agentTypeProperty(t.sw, "Roster agent type to take over."),
|
||||
"note": {Type: "string", Description: "Optional handoff note delivered to the new member's inbox."},
|
||||
"team": {Type: "string", Description: "Team the task belongs to. Defaults to \"default\"."},
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ package swarm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -10,6 +11,46 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestSpawnAndHandoffAgentTypeEnumReflectsRoster(t *testing.T) {
|
||||
sw := newSwarmFor(t, newLauncher(okFor))
|
||||
want := sw.Registry().AgentTypes() // sorted built-ins: subagent, teammate
|
||||
|
||||
spawnEnum := (&spawnTool{sw: sw}).Parameters().Properties["agent_type"].Enum
|
||||
if !slices.Equal(spawnEnum, want) {
|
||||
t.Fatalf("spawn agent_type enum = %v, want roster %v", spawnEnum, want)
|
||||
}
|
||||
if !slices.Contains(spawnEnum, "subagent") || !slices.Contains(spawnEnum, "teammate") {
|
||||
t.Fatalf("enum missing built-in roster types: %v", spawnEnum)
|
||||
}
|
||||
handoffEnum := (&handoffTool{sw: sw}).Parameters().Properties["to_agent_type"].Enum
|
||||
if !slices.Equal(handoffEnum, want) {
|
||||
t.Fatalf("handoff to_agent_type enum = %v, want roster %v", handoffEnum, want)
|
||||
}
|
||||
|
||||
// A user-registered custom type must extend the enum (not a hardcoded list).
|
||||
if err := sw.Registry().Register(Definition{AgentType: "researcher"}); err != nil {
|
||||
t.Fatalf("register custom agent: %v", err)
|
||||
}
|
||||
spawnEnum = (&spawnTool{sw: sw}).Parameters().Properties["agent_type"].Enum
|
||||
if !slices.Contains(spawnEnum, "researcher") {
|
||||
t.Fatalf("custom agent type not reflected in spawn enum: %v", spawnEnum)
|
||||
}
|
||||
handoffEnum = (&handoffTool{sw: sw}).Parameters().Properties["to_agent_type"].Enum
|
||||
if !slices.Contains(handoffEnum, "researcher") {
|
||||
t.Fatalf("custom agent type not reflected in handoff enum: %v", handoffEnum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTypePropertyFallsBackWithoutRoster(t *testing.T) {
|
||||
prop := agentTypeProperty(nil, "desc")
|
||||
if prop.Type != "string" || prop.Description != "desc" {
|
||||
t.Fatalf("unexpected property: %+v", prop)
|
||||
}
|
||||
if prop.Enum != nil {
|
||||
t.Fatalf("nil swarm must not advertise an (empty) enum, got %v", prop.Enum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapseRuneSafeTruncation(t *testing.T) {
|
||||
// 300 multi-byte runes: truncation must not split a rune (no invalid UTF-8).
|
||||
got := collapse(strings.Repeat("🚀", 300))
|
||||
|
||||
@@ -261,11 +261,13 @@ func (m model) swarmSpawnedAgents() []swarmAgent {
|
||||
switch a.state {
|
||||
case "done", "failed", "completed", "cancelled":
|
||||
a.finishing = true
|
||||
// A member drops once ITS OWN task completes — not when the whole turn
|
||||
// ends: fade out over the linger window from when it was first seen
|
||||
// finished (stamped each tick by stampSwarmDone), then remove. This
|
||||
// holds whether or not the overall run is still in flight, mirroring
|
||||
// how finished specialists drop (sidebarSpecialists).
|
||||
if m.pending {
|
||||
// Run still going: keep it visible and clickable, no fade.
|
||||
a.finishedAt = time.Time{}
|
||||
live = append(live, a)
|
||||
continue
|
||||
}
|
||||
// Turn ended: fade out over the linger window, then drop.
|
||||
doneAt, stamped := m.swarmDoneAt[a.id]
|
||||
if stamped && m.now().Sub(doneAt) >= sidebarAgentLinger {
|
||||
continue // past the linger window — remove
|
||||
|
||||
@@ -391,32 +391,32 @@ func TestSidebarShowsSwarmSpawnedAgents(t *testing.T) {
|
||||
// row (e.g. a resumed transcript that dropped the call): the member is still
|
||||
// A finished member stays visible (and clickable) while the run is still in
|
||||
// flight, so the user can inspect it; only once the turn ends does it drop.
|
||||
func TestSwarmAgentDropsOnOwnCompletionEvenMidRun(t *testing.T) {
|
||||
func TestSwarmAgentsPersistWhileRunInFlight(t *testing.T) {
|
||||
base := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
|
||||
m := sidebarTestModel()
|
||||
m.now = func() time.Time { return base }
|
||||
m.pending = true // run STILL going — a member must still drop on its OWN completion
|
||||
m.pending = true // run still going
|
||||
m.activeRunID = 7 // exercise the run-scoped filter with a non-zero id
|
||||
m.transcript = append(m.transcript,
|
||||
transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "build homepage", runID: 7},
|
||||
transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 7},
|
||||
transcriptRow{kind: rowToolResult, tool: "swarm_collect", detail: "Results: 1 task(s)\n- subagent-1 [done] build homepage", runID: 7},
|
||||
)
|
||||
// Just finished, still within the linger window: shows briefly with a fading ✓.
|
||||
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-sidebarAgentLinger / 2)}
|
||||
// Long past the linger window — but it must still show while pending.
|
||||
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-10 * sidebarAgentLinger)}
|
||||
|
||||
agents := m.swarmSpawnedAgents()
|
||||
if len(agents) != 1 {
|
||||
t.Fatalf("within the linger window a finished member should still show (fading), got %d: %+v", len(agents), agents)
|
||||
t.Fatalf("a finished member must stay while the run is in flight, got %d: %+v", len(agents), agents)
|
||||
}
|
||||
if !agents[0].finishing {
|
||||
t.Fatalf("a finished member should render done (✓), got %+v", agents[0])
|
||||
}
|
||||
|
||||
// Past the linger window: it drops — even though the overall run is still in
|
||||
// flight (previously a finished member lingered until the whole turn ended).
|
||||
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-2 * sidebarAgentLinger)}
|
||||
// Once the turn ends, the long-finished member fades out and drops.
|
||||
m.pending = false
|
||||
if got := len(m.swarmSpawnedAgents()); got != 0 {
|
||||
t.Fatalf("a member past its linger window must drop on its own completion mid-run, got %d", got)
|
||||
t.Fatalf("after the turn ends a long-finished member should drop, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user