Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions docs/spec/SPEC-002-session-lifecycle-and-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,25 @@ This enables:

## Turn Lifecycle

1. `SendUserMessage` command accepted after policy checks.
2. Actor appends user message to `SessionState.History`.
3. Actor invokes configured `IChatClient` via `ChatMessageConverter`.
4. Actor persists `TurnRecorded` event and applies to state.
5. Actor emits typed `SessionOutput` events to subscribers.
6. Actor checks compaction threshold.
1. `SendUserMessage` passes policy and complete input compatibility checks.
2. Actor appends the user message to `SessionState.History`.
3. Actor checks active history again before each model call.
4. Actor invokes the configured `IChatClient` via `ChatMessageConverter`.
5. Actor persists the `TurnRecorded` event and applies it to state.
6. Actor emits typed `SessionOutput` events to subscribers.
7. Actor checks the compaction threshold.

### Model Input Compatibility

The actor checks all active media references against the main model input
modalities. The check includes recovered history, new input, buffered input,
and tool-result media. An unknown persisted modality fails closed.

The actor rejects incompatible new input before it changes the session state.
It checks again before each model call to protect paths that add media during a
turn. The actor emits `ErrorCategory.InputCompatibility` with the unsupported
modalities and recovery guidance. It does not call the primary client,
fallback client, or provider when this local check fails.

### Tool Execution Pipeline

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ still read. `model list` reports an unparseable config instead of crashing.
`netclaw doctor --fix` applies only repairs it can derive safely; it does not
invent missing named definitions or role assignments.

### Session input compatibility errors

A saved session can contain image, audio, or video input from an earlier model.
Netclaw checks the complete active history before each model call. If the new
main model lacks a required modality, the turn stops before any provider or
fallback call.

The error names the unsupported modalities and the active model. Select a model
that accepts those modalities, or start a new conversation. Do not diagnose
this result as a provider outage. Netclaw also rejects an unknown saved modality
value instead of omitting that media.

### Adding GitHub Copilot

GitHub Copilot uses the OAuth device flow only — no API key. The operator
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-31
72 changes: 72 additions & 0 deletions openspec/changes/reject-incompatible-session-history/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
## Context

The session actor persists media references with each chat message.
The message assembler later restores those references as model input.
The current ingress check only examines media on the new user command.
A model change can therefore make recovered history incompatible with the active model.

The routing chat client treats request failures as provider failures.
The actor must reject incompatible input before that boundary.

## Goals / Non-Goals

**Goals:**

- Check the complete active session input before every model call.
- Reject current, recovered, and tool-produced unsupported media.
- Fail closed for an unknown persisted media modality.
- Keep provider fallback and health signals out of this local error path.

**Non-Goals:**

- Convert media to another modality.
- Select another model.
- Change the persisted media format.
- Add audio or video support.

## Decisions

### The session actor owns the compatibility check

The actor has the active model capabilities and the canonical session history.
It will check persisted media references before it calls `IChatClient`.

The provider client was rejected as the owner.
That location cannot separate local input errors from provider failover without wider routing changes.

### One pure check covers all media references

A pure helper will map each `MediaModality` value to a `ModelModality` flag.
The result will list required, unsupported, and unknown modalities.

The actor will use the helper before it accepts a new user turn.
The actor will use it again before each model call after a tool result.

### The actor will reject instead of removing content

The actor will not remove an unsupported media reference.
It will emit an input compatibility error with the active model and missing modalities.

This choice preserves the session record and prevents silent context loss.

### Local compatibility errors will not enter model routing

The actor will complete a rejected new command without a provider call.
If a tool adds incompatible media, the actor will fail the current turn before the next model call.
Neither path will persist a provider failure or activate fallback.

## Risks / Trade-offs

- [A historical session remains unusable with a text-only model] -> The error names the required modalities and gives model-selection guidance.
- [A corrupt modality value exists in storage] -> The check rejects the call and reports an unknown modality.
- [A future call path bypasses the ingress check] -> The second check at the model-call boundary remains authoritative.

## Migration Plan

The change needs no data migration.
Deployment changes only the result for an incompatible session.
A rollback restores the old provider-error behavior.

## Open Questions

None.
54 changes: 54 additions & 0 deletions openspec/changes/reject-incompatible-session-history/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
## Why

A resumed session can contain image content that the active model cannot accept.
The actor now discovers this mismatch only after it calls a provider.

Source PRDs: `PRD-001`, `PRD-005`
GitHub issue: `#1727`

## What Changes

- Check all active session media before each model call.
- Include recovered history, the new user message, and tool-produced media in the check.
- Reject unsupported or unknown media before the routing client receives a request.
- Show the unsupported modalities and clear operator recovery steps.
- Classify the result as an input compatibility error, not a provider failure.
- Do not activate a fallback model or a provider alert for this local error.

### In Scope

- Image, audio, and video compatibility checks for existing media records.
- A fail-closed result for unknown persisted modality values.
- Tests for recovery, new input, tool-loop input, and zero provider calls.

### Out of Scope

- A media proxy.
- Session model pins.
- An automatic switch to a compatible model.
- Audio or video feature support.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `netclaw-model-capabilities`: Require a complete session-input compatibility check before each model call.

## Impact

The change affects the session actor, media conversion, error output, and session tests.
It does not change provider APIs or persisted media records.

### Security Impact

The check fails closed for unknown media types.
It prevents incompatible content from crossing the provider boundary.

### Operational Impact

Operators receive a local compatibility error with model-selection guidance.
Provider health alerts and fallback logs remain reserved for provider failures.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
## ADDED Requirements

### Requirement: Complete session input compatibility check

The session actor SHALL check all active persisted media and all new media against the active model input modalities before each model call.
The check SHALL include recovered history and media that a tool adds during the current turn.
The actor SHALL reject an unsupported or unknown modality before any primary, fallback, or provider client receives a request.
The actor SHALL preserve all original media references and SHALL identify the incompatible modalities in the session error.

#### Scenario: Recovered image history meets a text-only model

- **GIVEN** a recovered session contains an image media reference
- **AND** the active model accepts text only
- **WHEN** the user resumes the session
- **THEN** the actor SHALL emit an input compatibility error
- **AND** the error SHALL identify image input as unsupported
- **AND** no primary, fallback, or provider client SHALL receive a request

#### Scenario: New unsupported media is rejected before turn admission

- **GIVEN** a new user command contains an image media reference
- **AND** the active model accepts text only
- **WHEN** the actor receives the command
- **THEN** the actor SHALL reject the command before it adds the user message to session state
- **AND** no model client SHALL receive a request

#### Scenario: Tool-produced media is checked before the next call

- **GIVEN** the active model call starts with compatible text input
- **AND** a tool result adds media that the active model cannot accept
- **WHEN** the actor prepares the next model call
- **THEN** the actor SHALL fail the current turn with an input compatibility error
- **AND** no later model client SHALL receive the incompatible request

#### Scenario: Unknown persisted modality fails closed

- **GIVEN** a session contains a media reference with an unknown modality value
- **WHEN** the actor prepares a model call
- **THEN** the actor SHALL emit an input compatibility error
- **AND** no model client SHALL receive a request

#### Scenario: Compatible media reaches the model

- **GIVEN** all session media modalities are accepted by the active model
- **WHEN** the actor prepares a model call
- **THEN** the actor SHALL preserve the media references
- **AND** the model call SHALL proceed through normal routing
35 changes: 35 additions & 0 deletions openspec/changes/reject-incompatible-session-history/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## 1. Compatibility Contract

- [x] 1.1 Add a pure media-to-model compatibility check with an unknown-modality result.
- [x] 1.2 Add a distinct input compatibility error category and user guidance.

## 2. Session Boundary

- [x] 2.1 Reject incompatible current and recovered media before turn admission.
- [x] 2.2 Check active history again before every model call after state changes.
- [x] 2.3 Preserve original media and keep local errors outside provider fallback and alerts.

## 3. Automated Proof

- [x] 3.1 Add unit tests for supported, unsupported, combined, and unknown modalities.
- [x] 3.2 Add actor tests for current media and recovered history with zero provider calls.
- [x] 3.3 Add a tool-message test and a second-boundary actor test.

## 4. Documentation and Gates

- [x] 4.1 Update operator guidance and the `netclaw-operations` system skill.
- [x] 4.2 Run targeted tests, the eval suite, repository quality gates, and OpenSpec validation.
- [x] 4.3 Update this checklist with final verification evidence.

## Verification Evidence

- Focused compatibility suite: 10 tests passed.
- `Netclaw.Actors.Tests`: 2,657 tests passed.
- `dotnet test Netclaw.slnx --no-restore`: all enabled tests passed.
- `dotnet slopwatch analyze`: 0 issues.
- `pwsh ./scripts/Add-FileHeaders.ps1 -Verify`: passed.
- `openspec validate reject-incompatible-session-history --strict`: passed.
- `git diff --check`: passed.
- Changed production and new test files pass the scoped format check.
- The full format check still reports pre-existing repository format debt.
- `./evals/run-evals.sh` could not start because no `NETCLAW_EVAL_*` target exists in this environment.
60 changes: 23 additions & 37 deletions src/Netclaw.Actors.Tests/Sessions/ModalityGateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@
namespace Netclaw.Actors.Tests.Sessions;

/// <summary>
/// Tests the modality gate in <see cref="LlmSessionActor"/>:
/// images sent to a text-only model are stripped; images sent to a vision model pass through.
/// Tests the modality gate in <see cref="LlmSessionActor"/>.
/// </summary>
public class ModalityGateTextOnlyTests : LlmSessionTestBase
{
Expand Down Expand Up @@ -46,14 +45,8 @@ protected override void ConfigureSessionServices(IServiceCollection services)
}

[Fact]
public async Task Image_with_text_on_text_only_model_surfaces_ingress_bug_and_still_calls_llm()
public async Task Image_with_text_on_text_only_model_is_rejected_before_model_call()
{
// The strict-consumer contract treats an unsupported-modality media
// ref reaching the session actor as an ingress bug. The session still
// completes the turn (so the user gets a reply) but the offending refs
// are dropped and a [system] notice about the ingress bug is appended
// to the user message before it goes to the model. No legacy
// "[Images removed]" placeholder is emitted.
var sessionId = new SessionId("test-channel/modality-text-only");
var sessionManager = ActorRegistry.Get<SessionManagerActorKey>();
var subscriber = CreateTestProbe("modality-sub");
Expand All @@ -80,29 +73,27 @@ await sessionManager.Ask<CommandAck>(new SendUserMessage
]
}, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);

// The first output is the LLM response itself — there is no longer a
// separate "[Images removed]" TextOutput before the reply.
var textOutput = await subscriber.ExpectMsgAsync<TextOutput>(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains("fake", textOutput.Text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Images removed", textOutput.Text);
var error = await subscriber.ExpectMsgAsync<ErrorOutput>(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(ErrorCategory.InputCompatibility, error.Category);
Assert.Contains("Image", error.Message, StringComparison.Ordinal);
Assert.Contains("text-only-model", error.Message, StringComparison.Ordinal);

await subscriber.ExpectMsgAsync<TurnCompleted>(cancellationToken: TestContext.Current.CancellationToken);
var completed = await subscriber.ExpectMsgAsync<TurnCompleted>(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(TurnOutcome.Skipped, completed.Outcome);
Assert.Equal(0, _fakeChatClient.CallCount);

// LLM was called and saw the ingress-bug notice appended to the user text.
Assert.Equal(1, _fakeChatClient.CallCount);
Assert.NotEmpty(_fakeChatClient.ReceivedMessages);
var lastRequest = _fakeChatClient.ReceivedMessages[^1];
var concatenated = string.Join("\n", lastRequest.Select(m => m.Text ?? string.Empty));
Assert.Contains("ingress bug", concatenated, StringComparison.OrdinalIgnoreCase);
var joined = await sessionManager.Ask<SessionJoined>(new JoinSession(subscriber)
{
SessionId = sessionId,
Filter = OutputFilter.Full
}, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
Assert.Equal(0, joined.TurnCount);
Assert.Empty(joined.RecentMessages ?? []);
}

[Fact]
public async Task Image_only_message_on_text_only_model_still_calls_llm_with_ingress_bug_notice()
public async Task Image_only_message_on_text_only_model_is_rejected_before_model_call()
{
// Empty text body + only unsupported media. The strict-consumer
// contract appends the [system] ingress bug notice to the user
// content so the LLM has something to respond to. We'd rather the
// user get a reply explaining the situation than silence.
var sessionId = new SessionId("test-channel/modality-image-only");
var sessionManager = ActorRegistry.Get<SessionManagerActorKey>();
var subscriber = CreateTestProbe("modality-image-only-sub");
Expand All @@ -129,18 +120,13 @@ await sessionManager.Ask<CommandAck>(new SendUserMessage
]
}, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);

var reply = await subscriber.ExpectMsgAsync<TextOutput>(cancellationToken: TestContext.Current.CancellationToken);
Assert.DoesNotContain("Images removed", reply.Text);
var error = await subscriber.ExpectMsgAsync<ErrorOutput>(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(ErrorCategory.InputCompatibility, error.Category);
Assert.Contains("start a new conversation", error.Message, StringComparison.OrdinalIgnoreCase);

await subscriber.ExpectMsgAsync<TurnCompleted>(cancellationToken: TestContext.Current.CancellationToken);

// LLM was called once, and the user-visible content we sent it
// included the ingress-bug notice (not a legacy placeholder).
Assert.Equal(1, _fakeChatClient.CallCount);
Assert.NotEmpty(_fakeChatClient.ReceivedMessages);
var lastRequest = _fakeChatClient.ReceivedMessages[^1];
var concatenated = string.Join("\n", lastRequest.Select(m => m.Text ?? string.Empty));
Assert.Contains("ingress bug", concatenated, StringComparison.OrdinalIgnoreCase);
var completed = await subscriber.ExpectMsgAsync<TurnCompleted>(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(TurnOutcome.Skipped, completed.Outcome);
Assert.Equal(0, _fakeChatClient.CallCount);
}
}

Expand Down
Loading
Loading