mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(extension): split the session capability, and bound one queued prompt
session.read and session.submit are separate capabilities, so a mod that only reads usage cannot put words in front of the model. One prompt a mod queues is bounded by [mods] submit_bytes (8 KiB, 0 for none) and refused with a typed identity that does not spend the per-turn count. The docs say the turn a queued prompt starts is held to this session's own approvals and sandbox.
This commit is contained in:
1 parent
96f261b398
commit
2a9743d790
17 files changed
+90
-28
No files matched your search
@@ -104,9 +104,9 @@ Streams follow `extension/provider/stream/open` → `stream/chunk` →
|
||||
|
||||
## Structured UI
|
||||
|
||||
Extensions with the `session` capability read the session (`host/session/read`: `usage` or `messages`, redacted and bounded by the user's ceilings) and queue a prompt (`host/prompt/submit`).
|
||||
Extensions with the `session.read` capability read the session (`host/session/read`: `usage` or `messages`, redacted and bounded by the user's ceilings) and those with `session.submit` queue a prompt (`host/prompt/submit`); neither implies the other.
|
||||
|
||||
A queued prompt is the host's message about the extension, labelled with its source, never the user's words; the host may refuse it with a `refused` identity in the result (`submit_per_turn`, `no_session`).
|
||||
A queued prompt is the host's message about the extension, labelled with its source, never the user's words; the host may refuse it with a `refused` identity in the result (`submit_per_turn`, `submit_bytes`, `no_session`).
|
||||
|
||||
Extensions with the `ui` capability publish `status`, `card`, `form`, `notification`, `panel`, and `view` payloads (`host/ui/publish`) and ask questions (`host/ui/request`: confirm, input, select, multiselect).
|
||||
|
||||
|
||||
@@ -769,15 +769,19 @@ Events served:
|
||||
response's token counts. A mod that calls them is listed in the install
|
||||
preview as reading the session.
|
||||
- `prompt.submit(text)`: queues a prompt as the mod's own words, under the host's
|
||||
header, never as yours; `asUser` is not served. Over `[mods] submit_per_turn`
|
||||
(3 between two prompts you write) it rejects with `mod.submit_rate`, and with
|
||||
`mod.submit_refused` while the session has no saved file.
|
||||
header, never as yours; `asUser` is not served.
|
||||
- It rejects with `mod.submit_rate` over `[mods] submit_per_turn` (3 between two
|
||||
prompts you write), with `mod.submit_too_long` over `[mods] submit_bytes`
|
||||
(8 KiB, 0 for none), and with `mod.submit_refused` while the session has no saved file.
|
||||
- A call that needs an answer made while `session.start` runs rejects with
|
||||
`mod.not_ready`: that hook runs before the session is ready.
|
||||
- `tool.register/call/list` and `command.register/run/list`. Both register
|
||||
while `session.start` runs.
|
||||
- `ui.toast`, `status`, `notice`, `log`, `ask`, `resolve`, `invalidate`.
|
||||
|
||||
The turn a queued prompt starts is held to this session's own approvals and
|
||||
sandbox like any other turn; that, not the label, is what keeps it safe.
|
||||
|
||||
The ceilings above are in the user file only, and `0` removes one.
|
||||
|
||||
Anything else is registered and named in the mod's status as unserved; a call to
|
||||
|
||||
@@ -94,7 +94,7 @@ export function register(on) {
|
||||
});
|
||||
}`
|
||||
|
||||
// A mod that declared the session capability reads the transcript and queues a
|
||||
// A mod that declared the session capabilities reads the transcript and queues a
|
||||
// prompt; the model sees the queued text labelled as the mod's own, never as the
|
||||
// user's, and the read reaches it only through the context the mod chose to add.
|
||||
func TestEffectAModQueuesAPromptThatReachesTheModelLabelledAsTheMods(t *testing.T) {
|
||||
|
||||
@@ -42,6 +42,8 @@ func (s extensionSession) SubmitPrompt(_ context.Context, plugin string, p proto
|
||||
switch err := c.QueueExtensionPrompt(plugin, p.Text); {
|
||||
case errors.Is(err, control.ErrExtensionSubmitRate):
|
||||
return protocol.PromptSubmitResult{Refused: protocol.PromptSubmitRefusedPerTurn}, nil
|
||||
case errors.Is(err, control.ErrExtensionSubmitTooLong):
|
||||
return protocol.PromptSubmitResult{Refused: protocol.PromptSubmitRefusedTooLong}, nil
|
||||
case errors.Is(err, control.ErrInboxNeedsSession):
|
||||
return protocol.PromptSubmitResult{Refused: protocol.PromptSubmitRefusedNoSession}, nil
|
||||
case err != nil:
|
||||
|
||||
@@ -27,6 +27,8 @@ type ModsConfig struct {
|
||||
// write: it stops a mod feeding the session to itself. 0 removes it, a
|
||||
// deliberate act. User file only.
|
||||
SubmitPerTurn int `toml:"submit_per_turn"`
|
||||
// SubmitBytes bounds one prompt a mod queues; 0 removes the ceiling. User file only.
|
||||
SubmitBytes int `toml:"submit_bytes"`
|
||||
}
|
||||
|
||||
// Defaults of the ceilings above.
|
||||
@@ -35,15 +37,16 @@ const (
|
||||
DefaultModsContextBlocks = 8
|
||||
DefaultModsMessagesMax = 200
|
||||
DefaultModsSubmitPerTurn = 3
|
||||
DefaultModsSubmitBytes = 8192
|
||||
)
|
||||
|
||||
func defaultMods() ModsConfig {
|
||||
return ModsConfig{ContextBytes: DefaultModsContextBytes, ContextBlocks: DefaultModsContextBlocks, MessagesMax: DefaultModsMessagesMax, SubmitPerTurn: DefaultModsSubmitPerTurn}
|
||||
return ModsConfig{ContextBytes: DefaultModsContextBytes, ContextBlocks: DefaultModsContextBlocks, MessagesMax: DefaultModsMessagesMax, SubmitPerTurn: DefaultModsSubmitPerTurn, SubmitBytes: DefaultModsSubmitBytes}
|
||||
}
|
||||
|
||||
// ModsLimits are the user's ceilings on what a mod may add to a session.
|
||||
type ModsLimits struct {
|
||||
ContextBytes, ContextBlocks, MessagesMax, SubmitPerTurn int
|
||||
ContextBytes, ContextBlocks, MessagesMax, SubmitPerTurn, SubmitBytes int
|
||||
}
|
||||
|
||||
// ModsLimits reads the ceilings; a nil config answers the defaults.
|
||||
@@ -52,7 +55,7 @@ func (c *Config) ModsLimits() ModsLimits {
|
||||
if c != nil {
|
||||
m = c.Mods
|
||||
}
|
||||
return ModsLimits{ContextBytes: max(m.ContextBytes, 0), ContextBlocks: max(m.ContextBlocks, 0), MessagesMax: max(m.MessagesMax, 0), SubmitPerTurn: max(m.SubmitPerTurn, 0)}
|
||||
return ModsLimits{ContextBytes: max(m.ContextBytes, 0), ContextBlocks: max(m.ContextBlocks, 0), MessagesMax: max(m.MessagesMax, 0), SubmitPerTurn: max(m.SubmitPerTurn, 0), SubmitBytes: max(m.SubmitBytes, 0)}
|
||||
}
|
||||
|
||||
// ModsEnabled reports whether mods run.
|
||||
@@ -82,6 +85,7 @@ func (h heldScope) narrowMods(c *Config) {
|
||||
{"mods.context_blocks", c.Mods.ContextBlocks, h.mods.ContextBlocks},
|
||||
{"mods.messages_max", c.Mods.MessagesMax, h.mods.MessagesMax},
|
||||
{"mods.submit_per_turn", c.Mods.SubmitPerTurn, h.mods.SubmitPerTurn},
|
||||
{"mods.submit_bytes", c.Mods.SubmitBytes, h.mods.SubmitBytes},
|
||||
} {
|
||||
if f.have != f.was {
|
||||
c.ignoreProject(f.key, fmt.Sprint(f.have), ProjectUserOnly)
|
||||
@@ -92,7 +96,7 @@ func (h heldScope) narrowMods(c *Config) {
|
||||
|
||||
func renderModsSection(b *strings.Builder, c *Config) {
|
||||
custom := c.Mods.ContextBytes != DefaultModsContextBytes || c.Mods.ContextBlocks != DefaultModsContextBlocks ||
|
||||
c.Mods.MessagesMax != DefaultModsMessagesMax || c.Mods.SubmitPerTurn != DefaultModsSubmitPerTurn
|
||||
c.Mods.MessagesMax != DefaultModsMessagesMax || c.Mods.SubmitPerTurn != DefaultModsSubmitPerTurn || c.Mods.SubmitBytes != DefaultModsSubmitBytes
|
||||
if !c.Mods.Enabled && c.Mods.HookBudgetMS == 0 && !custom {
|
||||
return
|
||||
}
|
||||
@@ -106,6 +110,7 @@ func renderModsSection(b *strings.Builder, c *Config) {
|
||||
fmt.Fprintf(b, "context_blocks = %d # blocks a mod may add to one turn; 0 for no ceiling\n", c.Mods.ContextBlocks)
|
||||
fmt.Fprintf(b, "messages_max = %d # transcript items one read by a mod may return; 0 for no ceiling\n", c.Mods.MessagesMax)
|
||||
fmt.Fprintf(b, "submit_per_turn = %d # prompts mods may queue between two prompts you write; 0 for no ceiling\n", c.Mods.SubmitPerTurn)
|
||||
fmt.Fprintf(b, "submit_bytes = %d # bytes of one prompt a mod queues; 0 for no ceiling\n", c.Mods.SubmitBytes)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestTheModCeilingsHaveDefaultsAndAZeroIsAnExplicitChoice(t *testing.T) {
|
||||
want := ModsLimits{ContextBytes: 8192, ContextBlocks: 8, MessagesMax: 200, SubmitPerTurn: 3}
|
||||
want := ModsLimits{ContextBytes: 8192, ContextBlocks: 8, MessagesMax: 200, SubmitPerTurn: 3, SubmitBytes: 8192}
|
||||
if got := Default().ModsLimits(); got != want {
|
||||
t.Fatalf("defaults = %+v, want %+v", got, want)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func TestTheModCeilingsHaveDefaultsAndAZeroIsAnExplicitChoice(t *testing.T) {
|
||||
|
||||
// A repository could otherwise lift the ceiling on what a mod adds to a turn.
|
||||
func TestAProjectFileCannotRaiseWhatAModMayAdd(t *testing.T) {
|
||||
cfg, _ := loadScoped(t, "", "[mods]\ncontext_bytes = 0\ncontext_blocks = 0\nmessages_max = 0\nsubmit_per_turn = 0\n")
|
||||
cfg, _ := loadScoped(t, "", "[mods]\ncontext_bytes = 0\ncontext_blocks = 0\nmessages_max = 0\nsubmit_per_turn = 0\nsubmit_bytes = 0\n")
|
||||
if got, want := cfg.ModsLimits(), Default().ModsLimits(); got != want {
|
||||
t.Fatalf("a project file moved the ceilings to %+v", got)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ var constraints = map[string]Entry{
|
||||
"mods.context_blocks": {Min: 0, Max: 1024, Bounded: true},
|
||||
"mods.messages_max": {Min: 0, Max: 100000, Bounded: true},
|
||||
"mods.submit_per_turn": {Min: 0, Max: 1000, Bounded: true},
|
||||
"mods.submit_bytes": {Min: 0, Max: 1 << 24, Bounded: true},
|
||||
"agent.checkpoint_ceiling_ratio": {Min: 0.05, Max: 1, Bounded: true},
|
||||
"agent.compact_force_ratio": {Min: 0.05, Max: 1, Bounded: true},
|
||||
"agent.soft_compact_ratio": {Min: 0.05, Max: 1, Bounded: true},
|
||||
|
||||
@@ -238,6 +238,7 @@ var table = []Entry{
|
||||
{ID: "mods.context_blocks", Class: Guarded, Effect: NextTurn},
|
||||
{ID: "mods.messages_max", Class: Guarded, Effect: NextTurn},
|
||||
{ID: "mods.submit_per_turn", Class: Guarded, Effect: NextTurn},
|
||||
{ID: "mods.submit_bytes", Class: Guarded, Effect: NextTurn},
|
||||
{ID: "peers.enabled", Class: Elevated, Effect: NextTurn},
|
||||
{ID: "peers.dispatch", Class: Elevated, Effect: NextTurn},
|
||||
{ID: "peers.release_projects", Class: Guarded, Effect: NextTurn},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package protocol
|
||||
|
||||
// Session DTOs: a read-only view of the session, and a queued prompt, for an
|
||||
// extension that declared the "session" capability. Both ride Extension → Host
|
||||
// extension that declared the "session.read" or "session.submit" capability. Both ride Extension → Host
|
||||
// requests; text an extension sends reaches the model only as data from it.
|
||||
|
||||
// SessionReadKind names what host/session/read returns.
|
||||
@@ -50,6 +50,9 @@ type PromptSubmitParams struct {
|
||||
// queued prompts is spent.
|
||||
const PromptSubmitRefusedPerTurn = "submit_per_turn"
|
||||
|
||||
// PromptSubmitRefusedTooLong is Refused when one prompt is over the user's byte ceiling.
|
||||
const PromptSubmitRefusedTooLong = "submit_bytes"
|
||||
|
||||
// PromptSubmitRefusedNoSession is Refused when the session has no saved path yet,
|
||||
// so there is nowhere to queue.
|
||||
const PromptSubmitRefusedNoSession = "no_session"
|
||||
|
||||
@@ -16,11 +16,12 @@ type SessionHandler interface {
|
||||
SubmitPrompt(ctx context.Context, p protocol.PromptSubmitParams) (protocol.PromptSubmitResult, error)
|
||||
}
|
||||
|
||||
// sessionHandler is the handler for a call that needs the "session" capability:
|
||||
// the runtime must have declared it, and the host must serve it.
|
||||
func (c *Client) sessionHandler(method protocol.Method) (SessionHandler, error) {
|
||||
if c.rt == nil || !containsString(c.rt.Capabilities, "session") {
|
||||
return nil, &protocol.ProtocolError{Reason: protocol.ErrCapabilityNotDeclared, Message: fmt.Sprintf("extension %s called %s without the session capability", c.pluginID, method)}
|
||||
// sessionHandler is the handler for a call that needs a session capability:
|
||||
// "session.read" for reads, "session.submit" for queued prompts. The runtime
|
||||
// must have declared it, and the host must serve it.
|
||||
func (c *Client) sessionHandler(method protocol.Method, capability string) (SessionHandler, error) {
|
||||
if c.rt == nil || !containsString(c.rt.Capabilities, capability) {
|
||||
return nil, &protocol.ProtocolError{Reason: protocol.ErrCapabilityNotDeclared, Message: fmt.Sprintf("extension %s called %s without the %s capability", c.pluginID, method, capability)}
|
||||
}
|
||||
h, ok := c.ui.(SessionHandler)
|
||||
if !ok {
|
||||
@@ -34,7 +35,7 @@ func (c *Client) handleSessionRead(ctx context.Context, raw json.RawMessage) (an
|
||||
if err != nil {
|
||||
return nil, protocol.MustProtocolError(protocol.ErrInvalidParams).RPCError()
|
||||
}
|
||||
h, err := c.sessionHandler(protocol.MethodHostSessionRead)
|
||||
h, err := c.sessionHandler(protocol.MethodHostSessionRead, "session.read")
|
||||
if err != nil {
|
||||
return nil, mapHandlerError(err)
|
||||
}
|
||||
@@ -50,7 +51,7 @@ func (c *Client) handlePromptSubmit(ctx context.Context, raw json.RawMessage) (a
|
||||
if err != nil {
|
||||
return nil, protocol.MustProtocolError(protocol.ErrInvalidParams).RPCError()
|
||||
}
|
||||
h, err := c.sessionHandler(protocol.MethodHostPromptSubmit)
|
||||
h, err := c.sessionHandler(protocol.MethodHostPromptSubmit, "session.submit")
|
||||
if err != nil {
|
||||
return nil, mapHandlerError(err)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ func sessionClient(caps []string, ui UIHandler) *Client {
|
||||
}
|
||||
|
||||
// Session reads and prompt submissions are served only to a runtime that
|
||||
// declared the session capability, whatever the host could answer.
|
||||
// declared the matching capability (session.read, session.submit), whatever the
|
||||
// host could answer.
|
||||
func TestSessionCallsNeedTheDeclaredCapability(t *testing.T) {
|
||||
ui := &sessionUI{}
|
||||
read := json.RawMessage(`{"what":"messages","limit":5}`)
|
||||
@@ -46,7 +47,18 @@ func TestSessionCallsNeedTheDeclaredCapability(t *testing.T) {
|
||||
t.Fatalf("the host was asked %d reads and %d submits before the capability check", ui.reads, ui.submits)
|
||||
}
|
||||
|
||||
with := sessionClient([]string{"session"}, ui)
|
||||
// Each capability opens only its own call: a runtime that may read the session
|
||||
// cannot put words in front of the model, and the reverse.
|
||||
readOnly := sessionClient([]string{"session.read"}, ui)
|
||||
if _, err := readOnly.handlePromptSubmit(context.Background(), submit); err == nil {
|
||||
t.Fatal("a submit was served to a runtime that declared only session.read")
|
||||
}
|
||||
submitOnly := sessionClient([]string{"session.submit"}, ui)
|
||||
if _, err := submitOnly.handleSessionRead(context.Background(), read); err == nil {
|
||||
t.Fatal("a read was served to a runtime that declared only session.submit")
|
||||
}
|
||||
|
||||
with := sessionClient([]string{"session.read", "session.submit"}, ui)
|
||||
got, err := with.handleSessionRead(context.Background(), read)
|
||||
if err != nil || got.(protocol.SessionReadResult).Messages[0].Text != "messages" {
|
||||
t.Fatalf("read = %v, %v", got, err)
|
||||
@@ -58,7 +70,7 @@ func TestSessionCallsNeedTheDeclaredCapability(t *testing.T) {
|
||||
|
||||
// A host that serves UI but not sessions says so, the same way it does for UI.
|
||||
func TestSessionCallsWithoutAHostHandlerAreUnknownMethods(t *testing.T) {
|
||||
c := sessionClient([]string{"session"}, unavailableUIHandler{})
|
||||
c := sessionClient([]string{"session.read", "session.submit"}, unavailableUIHandler{})
|
||||
if _, err := c.handleSessionRead(context.Background(), json.RawMessage(`{"what":"usage"}`)); err == nil {
|
||||
t.Fatal("a read was served with no host handler")
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ func Synthesize(d Deps, m Module, rt Runtime, script string) pluginpkg.Installed
|
||||
KillOnTimeout: true,
|
||||
TimeoutMillis: timeout,
|
||||
Intercepts: append([]string(nil), Intercepts...),
|
||||
Capabilities: []string{"interceptors", "ui", "tools", "session"},
|
||||
Capabilities: []string{"interceptors", "ui", "tools", "session.read", "session.submit"},
|
||||
}
|
||||
pkg := pluginpkg.Package{Root: m.Root, ManifestKind: m.Installed.ManifestKind, Manifest: pluginpkg.Manifest{Name: m.Plugin, Version: m.Installed.Version, Runtime: spec}}
|
||||
installed := m.Installed
|
||||
|
||||
@@ -256,6 +256,7 @@ const $ = {
|
||||
if (opts?.asUser) throw unsupported("$.prompt.submit with asUser");
|
||||
const r = await callHost("host/prompt/submit", { text: String(text ?? "") });
|
||||
if (r.refused === "submit_per_turn") throw new ModError("mod.submit_rate", "the ceiling on prompts mods may queue between two of yours is spent");
|
||||
if (r.refused === "submit_bytes") throw new ModError("mod.submit_too_long", "the prompt is longer than the ceiling on one prompt a mod may queue");
|
||||
if (r.refused) throw new ModError("mod.submit_refused", `the host refused the prompt: ${r.refused}`);
|
||||
return { queued: r.queued === true };
|
||||
},
|
||||
|
||||
@@ -379,7 +379,7 @@ var runtimeNamedSlots = map[string]bool{
|
||||
"frontend_events": true,
|
||||
}
|
||||
|
||||
var runtimeCapabilities = []string{"interceptors", "strategies", "providers", "ui", "tools", "session"}
|
||||
var runtimeCapabilities = []string{"interceptors", "strategies", "providers", "ui", "tools", "session.read", "session.submit"}
|
||||
|
||||
var runtimeToolName = regexp.MustCompile(`^[A-Za-z0-9_-]{1,48}$`)
|
||||
|
||||
|
||||
@@ -357,7 +357,7 @@ func TestManifestV2RuntimeValidation(t *testing.T) {
|
||||
{"unknown replace slot", `{"command": "./run", "replaces": ["systemprompt"]}`, `runtime.replaces: unknown slot "systemprompt"`},
|
||||
{"bad tool slot", `{"command": "./run", "replaces": ["tool:"]}`, `runtime.replaces: invalid tool slot "tool:"`},
|
||||
{"bad provider slot", `{"command": "./run", "replaces": ["provider:openai"]}`, `runtime.replaces: invalid provider slot "provider:openai"`},
|
||||
{"unknown capability", `{"command": "./run", "capabilities": ["filesystem"]}`, `runtime.capabilities: unknown capability "filesystem" (want one of: interceptors, strategies, providers, ui, tools, session)`},
|
||||
{"unknown capability", `{"command": "./run", "capabilities": ["filesystem"]}`, `runtime.capabilities: unknown capability "filesystem" (want one of: interceptors, strategies, providers, ui, tools, session.read, session.submit)`},
|
||||
{"tools without capability", `{"command": "./run", "tools": [{"name": "t", "description": "d", "inputSchema": {"type": "object"}}]}`, `runtime.tools needs "tools" in runtime.capabilities`},
|
||||
{"bad tool name", `{"command": "./run", "capabilities": ["tools"], "tools": [{"name": "a b", "description": "d", "inputSchema": {"type": "object"}}]}`, `runtime.tools[0].name "a b"`},
|
||||
{"duplicate tool", `{"command": "./run", "capabilities": ["tools"], "tools": [{"name": "t", "description": "d", "inputSchema": {"type": "object"}}, {"name": "t", "description": "d", "inputSchema": {"type": "object"}}]}`, `runtime.tools: "t" is declared twice`},
|
||||
|
||||
@@ -17,7 +17,9 @@ import (
|
||||
// Typed identities of the refusals an extension's session calls can get.
|
||||
var (
|
||||
// ErrExtensionSubmitRate: the extension queued its share of prompts this turn.
|
||||
ErrExtensionSubmitRate = errors.New("extensions have queued as many prompts since you last wrote as the submit_per_turn ceiling allows")
|
||||
// ErrExtensionSubmitTooLong: one prompt over the user's submit_bytes ceiling.
|
||||
ErrExtensionSubmitTooLong = errors.New("an extension prompt is longer than the submit_bytes ceiling allows")
|
||||
ErrExtensionSubmitRate = errors.New("extensions have queued as many prompts since you last wrote as the submit_per_turn ceiling allows")
|
||||
// ErrExtensionReadKind: a read of something the host does not serve.
|
||||
ErrExtensionReadKind = errors.New("the host serves only usage and messages")
|
||||
)
|
||||
@@ -63,7 +65,11 @@ func (c *Controller) QueueExtensionPrompt(plugin, text string) error {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return errors.New("an extension prompt must have text")
|
||||
}
|
||||
if !c.extPrompts.take(config.LoadForEdit(config.UserConfigPath()).ModsLimits().SubmitPerTurn) {
|
||||
limits := config.LoadForEdit(config.UserConfigPath()).ModsLimits()
|
||||
if limits.SubmitBytes > 0 && len(text) > limits.SubmitBytes {
|
||||
return ErrExtensionSubmitTooLong
|
||||
}
|
||||
if !c.extPrompts.take(limits.SubmitPerTurn) {
|
||||
return ErrExtensionSubmitRate
|
||||
}
|
||||
source := tool.Provenance{Kind: tool.ProvenanceExtension, Source: plugin}
|
||||
|
||||
@@ -2,6 +2,7 @@ package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/ext/extension/protocol"
|
||||
@@ -100,6 +102,30 @@ func TestAnExtensionMayQueueOnlyTheUsersCountPerTurn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// One prompt is bounded by the user's byte ceiling, and a refused one does not
|
||||
// spend the per-turn count.
|
||||
func TestAnExtensionPromptOverTheByteCeilingIsRefusedAndSpendsNoCount(t *testing.T) {
|
||||
c, r := heldController(t)
|
||||
startTurn(t, c, r)
|
||||
long := strings.Repeat("x", config.DefaultModsSubmitBytes+1)
|
||||
for range 5 {
|
||||
if err := c.QueueExtensionPrompt("mod-x", long); !errors.Is(err, ErrExtensionSubmitTooLong) {
|
||||
t.Fatalf("an over-long prompt: %v", err)
|
||||
}
|
||||
}
|
||||
if err := c.QueueExtensionPrompt("mod-x", strings.Repeat("x", config.DefaultModsSubmitBytes)); err != nil {
|
||||
t.Fatalf("a prompt at the ceiling after five refusals: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(os.Getenv("REASONIX_HOME"), "config.toml")
|
||||
if err := os.WriteFile(path, []byte("[mods]\nsubmit_bytes = 0\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.QueueExtensionPrompt("mod-x", long); err != nil {
|
||||
t.Fatalf("submit_bytes = 0 still refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExtensionReadsTheLastMessagesRedactedAndBounded(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
dir := testenv.TempDir(t)
|
||||
|
||||
Reference in new issue
Block a user