fix(agents): preserve exact identity after rebase
This commit is contained in:
@@ -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
|
||||
|
||||
-100
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user