diff --git a/core/http/react-ui/src/utils/chatCommands.js b/core/http/react-ui/src/utils/chatCommands.js index e4dfc7edfc..b1b11ba8b7 100644 --- a/core/http/react-ui/src/utils/chatCommands.js +++ b/core/http/react-ui/src/utils/chatCommands.js @@ -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) } diff --git a/core/http/react-ui/tests/unit/chatcommands.test.mjs b/core/http/react-ui/tests/unit/chatcommands.test.mjs index 458adf0c5c..240a17f4da 100644 --- a/core/http/react-ui/tests/unit/chatcommands.test.mjs +++ b/core/http/react-ui/tests/unit/chatcommands.test.mjs @@ -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') +}) diff --git a/core/schema/chats.go b/core/schema/chats.go index 7f97644a53..9060f6b212 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -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"` diff --git a/core/services/studio/capabilities_test.go b/core/services/studio/capabilities_test.go index 7fbe415b53..d8a277d177 100644 --- a/core/services/studio/capabilities_test.go +++ b/core/services/studio/capabilities_test.go @@ -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") diff --git a/core/services/studio/documenteligibility_internal_test.go b/core/services/studio/documenteligibility_internal_test.go index b90ea48b68..2a87bb518e 100644 --- a/core/services/studio/documenteligibility_internal_test.go +++ b/core/services/studio/documenteligibility_internal_test.go @@ -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 diff --git a/core/services/studio/executionobservation.go b/core/services/studio/executionobservation.go index 38670f9f4d..807a44348e 100644 --- a/core/services/studio/executionobservation.go +++ b/core/services/studio/executionobservation.go @@ -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) } diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index 7716d7834b..ebd8d9a580 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -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.