fix(studio): do not report a draft chat as not selected

A chat that is not created yet has no add-on bound to it, so its
execution status cannot be read. The host now says so with the reason
draft, and the Home composer shows no commands warning for it.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 11:31:37 +00:00
1 parent da60e435be
commit d0ac371e01
7 files changed
+37 -5

No files matched your search

+2
View File
@@ -21,6 +21,8 @@ export function commandsState(res) {
return { state: 'host', reason: '' }
}
const observation = res?.execution_status
// A chat that is not created yet has no status to read: nothing to warn of.
if (observation?.unknown_reason === 'draft' && (state === 'available' || state === 'none')) return { state: state === 'none' ? 'none' : 'draft', reason: '' }
if (state === 'available') {
const isolation = observation?.isolation || res?.commands_isolation
if (observation && observation.readiness !== 'available') return { state: 'unknown', reason: plainExplanation(observation.explanation), code: reasonCode(observation) }
@@ -320,3 +320,12 @@ test('unknown status: each reason code has its own text key, and a code from a n
assert.equal(unknownTextKey(''), 'unknown')
assert.equal(commandsState({ commands: 'available', commands_provider: 'sandbox', execution_status: { readiness: 'unknown', isolation: 'unknown', unknown_reason: 'x' } }).code, '')
})
test('a draft chat has no execution status to warn about', async () => {
const { commandsState } = await import('../../src/utils/chatCommands.js')
const draft = { readiness: 'unknown', isolation: 'unknown', unknown_reason: 'draft' }
const available = commandsState({ commands: 'available', commands_provider: 'sandbox', execution_status: draft })
assert.equal(available.state, 'draft')
assert.equal(commandsNotice(available.state), null)
assert.equal(commandsState({ commands: 'none', execution_status: draft }).state, 'none')
})
+2 -2
View File
@@ -1756,8 +1756,8 @@ type ChatsExecutionStatus struct {
Isolation string `json:"isolation"`
Explanation string `json:"explanation,omitempty"`
// UnknownReason says why the status is unknown: owner_unreachable,
// not_reported, stale, invalid_status, not_selected, no_answer or
// reported_unknown. Absent when the status is known.
// not_reported, stale, invalid_status, not_selected, no_answer,
// reported_unknown or draft (the chat does not exist yet). Absent when the status is known.
UnknownReason string `json:"unknown_reason,omitempty" example:"owner_unreachable"`
Generation string `json:"generation,omitempty"`
Incarnation string `json:"incarnation,omitempty"`
+11
View File
@@ -283,6 +283,17 @@ var _ = Describe("execution presentation DTO", func() {
})
})
var _ = Describe("the execution status of a chat that does not exist yet", func() {
It("says the chat is a draft, not that the add-on is not selected", func() {
broker := presentationAddons{fakeAddons: &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}, bound: true}
svc, _, _ := newCapsChat(broker, nil)
_, c, err := svc.DraftAddons(context.Background(), alice, "m", "")
Expect(err).NotTo(HaveOccurred())
Expect(c.ExecutionStatus).NotTo(BeNil())
Expect(c.ExecutionStatus.UnknownReason).To(Equal("draft"))
})
})
var _ = Describe("a second provider of commands", func() {
runbox := func() []studio.AddonTool {
bash := addonTool("runbox", "bash")
@@ -121,7 +121,7 @@ var _ = Describe("document provider matrix", func() {
}
b.sets = append(b.sets, set)
}
ctx, closeScope := svc.executionScope(ctx, "alice", "")
ctx, closeScope := svc.executionScope(ctx, "alice", "c1")
defer closeScope()
list, _, _, err := svc.chatSkillSet(ctx, "alice", nil, off, sc, command)
Expect(err).NotTo(HaveOccurred())
@@ -403,7 +403,7 @@ func documentCommands(s *Service, ctx context.Context, p string, rec session.Cha
// chatSkillNotes resolves the skills of a chat as the turn does and returns
// the notes it gets.
func chatSkillNotes(svc *Service, ctx context.Context, off []string, command Commands) ([]skills.Skill, []string, error) {
ctx, closeScope := svc.executionScope(ctx, "alice", "")
ctx, closeScope := svc.executionScope(ctx, "alice", "c1")
defer closeScope()
list, notes, _, err := svc.chatSkillSet(ctx, "alice", nil, off, nil, command)
return list, notes, err
@@ -41,6 +41,8 @@ const (
UnknownNoAnswer UnknownReason = "no_answer"
// UnknownReportedUnknown: the add-on answered that it cannot tell.
UnknownReportedUnknown UnknownReason = "reported_unknown"
// UnknownDraft: the chat does not exist yet, so no add-on is bound to it.
UnknownDraft UnknownReason = "draft"
)
// ExecutionDetail is what the provider said beside the typed value, plus why
@@ -127,6 +129,9 @@ func ObserveExecution(ctx context.Context, user, addon string) (model.Value, Exe
if user != scope.user {
return model.Unknown(), ExecutionDetail{Failure: UnknownNotSelected}
}
if scope.conversation == "" {
return model.Unknown(), ExecutionDetail{Failure: UnknownDraft}
}
scope.mu.Lock()
if !scope.closed && scope.provider != nil && (!scope.started || (scope.retriable && time.Since(scope.attempted) >= observationRetryAfter)) {
// The first read of a scope stands. A turn can outlive a restart of the
@@ -201,6 +206,8 @@ func (r UnknownReason) Text() string {
return "the add-on did not answer in time"
case UnknownReportedUnknown:
return "the add-on reported that it cannot tell"
case UnknownDraft:
return "the chat has not been created yet"
}
return "the reason is not known"
}
@@ -223,6 +230,9 @@ type ExecutionStatusView struct {
func executionStatusView(ctx context.Context, user, addon string) *ExecutionStatusView {
v, d := model.Unknown(), ExecutionDetail{Failure: UnknownNotSelected}
if scope, ok := ctx.Value(executionObservationKey{}).(*executionObservation); ok && scope.user == user && scope.conversation == "" {
addon, d.Failure = "", UnknownDraft // a draft has no add-on bound yet
}
if addon != "" {
v, d = ObserveExecution(ctx, user, addon)
}
+1 -1
View File
@@ -263,7 +263,7 @@ not an addon ID or a private settings payload. Prompt construction, command
status and nested egress reads share one observation within a turn. Priming and
standalone command-status or egress requests have separate, bounded scopes.
Consumer cancellation stops that wait; turn completion cancels the observation.
A failed read settles as unknown with a reason (`unknown_reason`: `owner_unreachable`, `not_reported`, `stale`, `invalid_status`, `not_selected`, `no_answer` or `reported_unknown`), which the Tools menu shows. It is not retried at once. After a failure that a restart of the add-on owner can cause (`owner_unreachable`, `no_answer`, `stale`), a scope that is still open (a long turn) binds and reads again, at most once every five seconds. A refusal of the binding or of the payload is not retried. Each
A failed read settles as unknown with a reason (`unknown_reason`: `owner_unreachable`, `not_reported`, `stale`, `invalid_status`, `not_selected`, `no_answer`, `reported_unknown` or `draft`, for a chat that is not created yet), which the Tools menu shows. It is not retried at once. After a failure that a restart of the add-on owner can cause (`owner_unreachable`, `no_answer`, `stale`), a scope that is still open (a long turn) binds and reads again, at most once every five seconds. A refusal of the binding or of the payload is not retried. Each
consumption revalidates the complete owner binding, including conversation,
current selection identity and runtime incarnation.