fix(agents): preserve exact identity after rebase

This commit is contained in:
ayattara
2026-09-07 19:23:03 +00:00
parent 638878075f
commit b67a89c429
6 changed files with 17 additions and 228 deletions
@@ -544,8 +544,7 @@ func (m *Manager) reapplySessionModelAfterReset(
}
if decision.EffectiveModel != "" &&
(decision.Outcome == ModelSelectionOutcomeApplied ||
decision.Outcome == ModelSelectionOutcomeExplicitFallback ||
decision.Outcome == ModelSelectionOutcomeUniqueVariation) {
decision.Outcome == ModelSelectionOutcomeExplicitFallback) {
m.logger.Info("re-applied session model after context reset",
zap.String("execution_id", execution.ID),
zap.String("session_id", execution.SessionID),
@@ -513,12 +513,10 @@ func (sm *SessionManager) applyStartModelPolicyToEffectiveModel(
}
effective.decision = decision
if decision.Outcome == ModelSelectionOutcomeApplied ||
decision.Outcome == ModelSelectionOutcomeExplicitFallback ||
decision.Outcome == ModelSelectionOutcomeUniqueVariation {
decision.Outcome == ModelSelectionOutcomeExplicitFallback {
effective.appliedModel = decision.EffectiveModel
}
if decision.Outcome == ModelSelectionOutcomeExplicitFallback ||
decision.Outcome == ModelSelectionOutcomeUniqueVariation {
if decision.Outcome == ModelSelectionOutcomeExplicitFallback {
effective.model = decision.EffectiveModel
}
return effective
@@ -1,100 +0,0 @@
package lifecycle
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/agent/agents"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/pkg/agent"
)
func TestInitializeAndPrompt_UniqueVariationPublishesProviderNeutralWarning(t *testing.T) {
mock := newMockAgentServer(t)
defer mock.Close()
log := newSessionTestLogger()
stopCh := newTestStopCh(t)
sm := NewSessionManager(log, stopCh)
streamMgr := NewStreamManager(log, StreamCallbacks{
OnAgentEvent: func(execution *AgentExecution, event agentctl.AgentEvent) {},
}, nil, stopCh)
cleanupStreamManager(t, stopCh, streamMgr)
eventBus := &MockEventBusWithTracking{}
sm.SetDependencies(NewEventPublisher(eventBus, log), streamMgr, nil, nil)
client := createTestClient(t, mock.server.URL)
defer client.Close()
execution := &AgentExecution{
ID: "exec-unique-variation",
TaskID: "task-1",
SessionID: "session-1",
WorkspacePath: "/workspace",
agentctl: client,
promptDoneCh: make(chan PromptCompletionSignal, 1),
}
execution.SetModelState(modelState("opus[1m]"))
agentConfig := &testAgent{
id: "test-agent",
enabled: true,
runtimeConfig: &agents.RuntimeConfig{
Cmd: agents.NewCommand("test-agent"),
Protocol: agent.ProtocolACP,
SessionConfig: agents.SessionConfig{},
ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, nil,
func(executionID string) error { return nil },
StartModelPolicy{Model: "opus"}, "plan", nil)
if err != nil {
t.Fatalf("InitializeAndPrompt failed: %v", err)
}
setModelCalls := 0
for _, action := range mock.getActionLog() {
if action == "agent.session.set_model" {
setModelCalls++
}
}
if setModelCalls != 1 {
t.Fatalf("set_model calls = %d, want exactly 1", setModelCalls)
}
var warning *streams.ModelSelectionWarning
legacyFallbackEvents := 0
for _, event := range eventBus.getStreamEvents() {
if event.Data == nil {
continue
}
if event.Data.ModelSelectionWarning != nil {
warning = event.Data.ModelSelectionWarning
}
if event.Data.Type == streams.EventTypeSessionModelFallback {
legacyFallbackEvents++
}
}
if warning == nil {
t.Fatal("expected a model-selection warning event")
}
if warning.RequestedModel != "opus" {
t.Errorf("warning requested model = %q, want opus", warning.RequestedModel)
}
if warning.EffectiveModel != "opus[1m]" {
t.Errorf("warning effective model = %q, want opus[1m]", warning.EffectiveModel)
}
if warning.FallbackModel != "" {
t.Errorf("warning fallback model = %q, want empty", warning.FallbackModel)
}
if warning.Reason != ModelSelectionReasonUniqueVariationApplied {
t.Errorf("warning reason = %q, want %q", warning.Reason, ModelSelectionReasonUniqueVariationApplied)
}
if legacyFallbackEvents != 0 {
t.Fatalf("legacy fallback events = %d, want zero for unique variation", legacyFallbackEvents)
}
}
@@ -30,6 +30,16 @@ must stop the launch before inference rather than silently run another model.
`auto_fallback=true`, provider-default continuation remains explicitly
authorized and visible.
### REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002: Resolve one advertised model variation
**Status:** Superseded by the exact-profile amendment to
`REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001`. An inferred variation is not an
exact model identity and therefore cannot authorize inference.
- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.1:** This superseded requirement
has no active runtime behavior; model selection follows
`REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001`.
## System design
The migrated technical source is split into [part 1](../system-design/no-silent-model-fallback-01.md), [part 2](../system-design/no-silent-model-fallback-02.md).
@@ -155,120 +155,9 @@ The executor ACP catalog is authoritative for launch. Exact-profile model
identity is enforced by [ADR-2026-09-06](../../../decisions/2026-09-06-exact-profile-model-identity.md).
The host probe remains an editing hint and does not block profile selection.
## Unique model-variation resolution
### Matching contract
ACP model IDs remain opaque and case-sensitive. Kandev recognizes only this
structural relationship:
```text
<requested>[<variant>]
```
The requested ID must contain no `[` or `]` character. The advertised ID must
start with the complete requested ID followed by `[`, end with `]`, and contain
a non-empty variant with no bracket character. Kandev does not trim, parse, or
rank the variant.
Examples:
| Requested | Advertised candidates | Result |
| --- | --- | --- |
| `opus` | `opus[1m]` | Apply `opus[1m]`. |
| `opus` | `opus[270k]`, `opus[1m, fast]` | Do not infer. |
| `opus[1m]` | `opus[270k]` | Do not infer. |
| `opus` | `myopus[1m]`, `opus-extra[1m]` | Do not infer. |
| `opus` | `opus[]`, `opus[1m][fast]` | Do not infer. |
Duplicate catalog rows with the same model ID count as one distinct candidate.
This rule keeps the outcome stable when an agent repeats a catalog entry.
### Resolution order
For profiles with `auto_fallback = false`, the lifecycle applies this order
after ACP initialization:
1. Apply the exact requested model when advertised.
2. Apply the configured explicit fallback when advertised.
3. Apply the requested model's unique advertised variation when one exists.
4. Otherwise, make no model-selection call and keep the provider current or
default model.
An unadvertised explicit fallback does not block step 3. More than one distinct
variation blocks inference even when one candidate appears first or is the
provider current model.
When `auto_fallback = true`, the legacy path takes precedence when the
requested model is absent: Kandev ignores the explicit fallback and does not
infer a variation. It makes no model-selection call and continues on the
provider current or default model with a warning. Apply errors for an
advertised model remain best-effort in that mode.
`applyStartModelPolicy` remains the single runtime owner. A pure helper returns
the unique candidate or no candidate. Initial launch, context reset, and
workspace rebind continue to call the same policy.
### Decision and warning contract
The typed decision adds the outcome `unique_variation` and the stable reason
`unique_variation_applied`. `EffectiveModel` contains the advertised variation.
`FallbackModel` remains reserved for a configured explicit fallback and is not
overloaded with an inferred value.
Kandev calls `SetModel` only with the candidate's exact advertised ID. A failed
call follows the existing advertised-model rule: fail the operation unless
legacy `auto_fallback` permits provider-default continuation. A
method-not-supported response also follows the current provider-default path.
The persisted `model_selection_warning` event uses the new reason and existing
requested and effective model fields. The profile remains unchanged. The
decision ID continues to include the requested model, reason, and effective
model, so the warning stays idempotent for one session decision.
### Host advisory and mobile behavior
The frontend uses a small pure helper with the same case-sensitive matching
table. This calculation is advisory because the host catalog can differ from
the selected executor catalog.
When the host advertises one variation, the task profile option remains
selectable and identifies the possible target in its existing amber advisory.
The profile editor keeps the saved bare ID visible and describes the unique
host variation instead of reporting that the model is simply gone. Selecting
the advertised variation remains an explicit way to update the profile.
When the host advertises zero or multiple variations, the current missing-model
presentation remains. The runtime can still reach a different decision from
the selected executor catalog.
The task-create advisory keeps its existing desktop tooltip and coarse-pointer
drawer. The profile editor uses its existing responsive selector. The durable
chat warning uses the existing status-message layout on desktop and mobile.
This change adds no nested scroll area or hover-only information.
### Tests and operational evidence
Backend table tests cover exact precedence, explicit-fallback precedence,
one variation, duplicate rows, multiple variations, bracketed requests,
case differences, malformed IDs, method-not-supported, and apply errors.
Existing launch, reset, rebind, warning persistence, and reload tests cover the
new decision outcome.
Frontend unit tests mirror the matching table and cover the task picker,
profile editor, and localized status warning. Existing desktop and mobile model
mismatch E2E flows add the unique and ambiguous catalog cases.
Structured lifecycle logs record the requested model, effective model, outcome,
and distinct candidate count. They do not record credentials or configuration
file contents.
No database migration is required.
## Related decisions
- [Let the Executor Own Model Selection](../../../decisions/2026-08-15-executor-authoritative-model-selection.md)
- [Resolve Only One Advertised Model Variation](../../../decisions/2026-09-07-unique-model-variation-resolution.md)
## Backend Changes
@@ -12,15 +12,14 @@ owners:
## Purpose and boundaries
This design records risks for executor-authoritative model fallback and unique
variation resolution.
This design records risks for executor-authoritative model fallback.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001` | [Migrated source detail](#migrated-source-detail) |
| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002` | [Risks & Open Questions](#risks--open-questions) |
| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002` | Superseded by exact-profile identity. |
## Migrated source detail
@@ -55,14 +54,8 @@ variation resolution.
- **Hover is supplementary**: every info icon is focusable, and coarse-pointer
devices receive the same content in a drawer. The visible option helper copy
remains the baseline explanation.
- **Provider-specific labels remain opaque**: `1m`, `270k`, and `fast` have no
Kandev-defined order. More than one variation therefore fails closed to the
existing provider-default path.
- **Host and executor decisions can differ**: a unique host variation is only
an advisory. The selected executor can advertise zero, one, or multiple
variations at launch.
- **Bracketed requests do not drift sideways**: a request such as `opus[1m]`
does not infer `opus[270k]`. The user must select another explicit model.
- **Provider-specific labels remain opaque**: Kandev does not infer a variation
from an exact requested model. The user must select another explicit model.
- **Legacy automatic fallback remains unchanged**: when `auto_fallback` is
enabled and the requested model is absent, Kandev does not apply an explicit
fallback or infer a variation. It continues with the provider default.