Skip to content

Commit 920c7b3

Browse files
authored
[codex] Refactor App Studio assistant UX around Eino (#322)
* Refactor App Studio assistant UX around Eino * fix: address app studio review findings * fix: harden app studio resume edge cases * fix: harden app studio pending resume UI
1 parent d017adf commit 920c7b3

41 files changed

Lines changed: 5135 additions & 3444 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

providers/app-studio/README.md

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,6 @@ tool.
9393
## Runtime workers
9494

9595
App Studio does not run build, test, preview, or log commands inside the
96-
provider pod. Runtime commands are modeled behind an internal worker boundary so
97-
future implementations can use isolated tenant/project-scoped workers with their
98-
own resource limits, audit trail, and cancellation model.
99-
100-
By default no runtime worker is configured, so runtime tools are not advertised
101-
to the assistant. If a deployment injects a worker, both `verify_project_runtime`
102-
and `runtime_command` still require explicit user approval before the worker is
103-
started. `verify_project_runtime` uses fixed App Studio verification presets and
104-
routes them through the same worker boundary instead of running commands in the
105-
provider pod.
96+
provider pod. The assistant can recommend build and test checks from project
97+
context, but App Studio no longer advertises runtime execution tools until a
98+
tenant-isolated worker implementation is productized.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
Copyright 2026 The Faros Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package api
18+
19+
import (
20+
"strings"
21+
22+
"github.com/google/uuid"
23+
)
24+
25+
const (
26+
projectBuilderEventPlanReady = "plan_ready"
27+
projectBuilderEventPlanApproved = "plan_approved"
28+
projectBuilderEventWorkspaceChanged = "workspace_changed"
29+
)
30+
31+
type projectBuilderEventView struct {
32+
ID string `json:"id"`
33+
Type string `json:"type"`
34+
}
35+
36+
func newProjectBuilderEventID() string {
37+
return "evt-" + uuid.NewString()
38+
}
39+
40+
func projectAssistantBuilderEventView(
41+
eventType string,
42+
) *projectBuilderEventView {
43+
eventType = strings.TrimSpace(eventType)
44+
if eventType == "" {
45+
return nil
46+
}
47+
return &projectBuilderEventView{
48+
ID: newProjectBuilderEventID(),
49+
Type: eventType,
50+
}
51+
}
52+
53+
func emitProjectAssistantBuilderEvent(callbacks projectAssistantStreamCallbacks, view *projectBuilderEventView) {
54+
if view == nil || callbacks.OnAssistantEvent == nil {
55+
return
56+
}
57+
callbacks.OnAssistantEvent(projectAssistantEvent{
58+
Type: projectAssistantEventBuilderEvent,
59+
BuilderEvent: view,
60+
})
61+
}

providers/app-studio/api/assistant_checkpoint.go

Lines changed: 325 additions & 35 deletions
Large diffs are not rendered by default.

providers/app-studio/api/assistant_contract.go

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ type projectAssistantEngine interface {
3535
StreamProjectAssistant(
3636
context.Context,
3737
projectAssistantRunRequest,
38-
projectAssistantEventSink,
3938
) (projectAssistantRunResult, error)
4039
ResumeProjectAssistant(
4140
context.Context,
@@ -65,39 +64,36 @@ type projectAssistantRunRequest struct {
6564
}
6665

6766
type projectAssistantRunResult struct {
68-
Content string
69-
Events []projectAssistantEvent
70-
ToolCalls []projectAssistantToolCall
71-
}
72-
73-
type projectAssistantEventSink interface {
74-
EmitProjectAssistantEvent(context.Context, projectAssistantEvent) error
67+
Content string
7568
}
7669

7770
type projectAssistantEvent struct {
78-
Type projectAssistantEventType `json:"type"`
79-
ID string `json:"id,omitempty"`
80-
MessageID string `json:"messageID,omitempty"`
81-
ToolCall *projectAssistantToolCall `json:"toolCall,omitempty"`
82-
Permission *projectAssistantPermission `json:"permission,omitempty"`
83-
Checkpoint *projectAssistantCheckpoint `json:"checkpoint,omitempty"`
84-
Delta string `json:"delta,omitempty"`
85-
Status string `json:"status,omitempty"`
86-
Error string `json:"error,omitempty"`
87-
Metadata json.RawMessage `json:"metadata,omitempty"`
88-
CreatedAt *time.Time `json:"createdAt,omitempty"`
71+
Type projectAssistantEventType `json:"type"`
72+
ID string `json:"id,omitempty"`
73+
MessageID string `json:"messageID,omitempty"`
74+
ToolCall *projectAssistantToolCall `json:"toolCall,omitempty"`
75+
Permission *projectAssistantPermission `json:"permission,omitempty"`
76+
FollowUp *projectAssistantFollowUp `json:"followUp,omitempty"`
77+
Checkpoint *projectAssistantCheckpoint `json:"checkpoint,omitempty"`
78+
BuilderEvent *projectBuilderEventView `json:"builderEvent,omitempty"`
79+
Delta string `json:"delta,omitempty"`
80+
Status string `json:"status,omitempty"`
81+
Error string `json:"error,omitempty"`
82+
Metadata json.RawMessage `json:"metadata,omitempty"`
83+
CreatedAt *time.Time `json:"createdAt,omitempty"`
8984
}
9085

9186
type projectAssistantEventType string
9287

9388
const (
94-
projectAssistantEventRunStarted projectAssistantEventType = "run_started"
9589
projectAssistantEventMessageDelta projectAssistantEventType = "message_delta"
9690
projectAssistantEventStatus projectAssistantEventType = "status"
9791
projectAssistantEventToolCallStarted projectAssistantEventType = "tool_call_started"
9892
projectAssistantEventToolCallFinished projectAssistantEventType = "tool_call_finished"
9993
projectAssistantEventPermissionNeeded projectAssistantEventType = "permission_required"
94+
projectAssistantEventInputNeeded projectAssistantEventType = "input_required"
10095
projectAssistantEventCheckpointSaved projectAssistantEventType = "checkpoint_saved"
96+
projectAssistantEventBuilderEvent projectAssistantEventType = "builder_event"
10197
projectAssistantEventRunFailed projectAssistantEventType = "run_failed"
10298
projectAssistantEventRunFinished projectAssistantEventType = "run_finished"
10399
)
@@ -121,6 +117,13 @@ type projectAssistantPermission struct {
121117
Input json.RawMessage `json:"input,omitempty"`
122118
}
123119

120+
type projectAssistantFollowUp struct {
121+
ID string `json:"id"`
122+
ToolCallID string `json:"toolCallID,omitempty"`
123+
Questions []string `json:"questions,omitempty"`
124+
Prompt string `json:"prompt,omitempty"`
125+
}
126+
124127
type projectAssistantCheckpoint struct {
125128
ID string `json:"id"`
126129
Reason string `json:"reason,omitempty"`

providers/app-studio/api/assistant_contract_test.go

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,6 @@ func TestProjectAssistantContractCanReferenceEinoADK(t *testing.T) {
3939
}
4040
}
4141

42-
func TestProjectAssistantEventSinkContract(t *testing.T) {
43-
sink := projectAssistantEventSink(projectAssistantEventSinkFunc(func(context.Context, projectAssistantEvent) error {
44-
return nil
45-
}))
46-
if err := sink.EmitProjectAssistantEvent(context.Background(), projectAssistantEvent{
47-
Type: projectAssistantEventRunStarted,
48-
}); err != nil {
49-
t.Fatalf("EmitProjectAssistantEvent returned error: %v", err)
50-
}
51-
}
52-
5342
func TestProjectAssistantEventOmitsEmptyOptionalTimestamps(t *testing.T) {
5443
payload, err := json.Marshal(projectAssistantEvent{
5544
Type: projectAssistantEventCheckpointSaved,
@@ -64,12 +53,3 @@ func TestProjectAssistantEventOmitsEmptyOptionalTimestamps(t *testing.T) {
6453
t.Fatalf("event encoded empty createdAt: %s", payload)
6554
}
6655
}
67-
68-
type projectAssistantEventSinkFunc func(context.Context, projectAssistantEvent) error
69-
70-
func (f projectAssistantEventSinkFunc) EmitProjectAssistantEvent(
71-
ctx context.Context,
72-
event projectAssistantEvent,
73-
) error {
74-
return f(ctx, event)
75-
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/*
2+
Copyright 2026 The Faros Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package api
18+
19+
import (
20+
"context"
21+
"errors"
22+
"io"
23+
"sort"
24+
"strings"
25+
"sync"
26+
27+
"github.com/cloudwego/eino/adk"
28+
"github.com/cloudwego/eino/callbacks"
29+
einomodel "github.com/cloudwego/eino/components/model"
30+
"github.com/cloudwego/eino/schema"
31+
)
32+
33+
func projectEinoAssistantRunOptions(req projectAssistantRunRequest, runState *projectEinoAssistantRunState) []adk.AgentRunOption {
34+
handler := newProjectEinoAssistantModelCallbackHandler(req.StreamCallbacks, runState)
35+
if handler == nil {
36+
return nil
37+
}
38+
return []adk.AgentRunOption{adk.WithCallbacks(handler)}
39+
}
40+
41+
func newProjectEinoAssistantModelCallbackHandler(streamCallbacks projectAssistantStreamCallbacks, runState *projectEinoAssistantRunState) callbacks.Handler {
42+
recorder := &projectEinoAssistantModelCallbackRecorder{
43+
streamCallbacks: streamCallbacks,
44+
runState: runState,
45+
}
46+
return callbacks.NewHandlerBuilder().
47+
OnStartFn(func(ctx context.Context, _ *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
48+
recorder.recordModelInput(input)
49+
return ctx
50+
}).
51+
OnEndFn(func(ctx context.Context, _ *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
52+
recorder.recordModelOutput(output)
53+
return ctx
54+
}).
55+
OnEndWithStreamOutputFn(func(ctx context.Context, _ *callbacks.RunInfo, output *schema.StreamReader[callbacks.CallbackOutput]) context.Context {
56+
recorder.recordModelStream(output)
57+
return ctx
58+
}).
59+
Build()
60+
}
61+
62+
type projectEinoAssistantModelCallbackRecorder struct {
63+
streamCallbacks projectAssistantStreamCallbacks
64+
runState *projectEinoAssistantRunState
65+
66+
mu sync.Mutex
67+
reportedToolPreparation bool
68+
}
69+
70+
func (r *projectEinoAssistantModelCallbackRecorder) recordModelInput(input callbacks.CallbackInput) {
71+
modelInput := einomodel.ConvCallbackInput(input)
72+
if modelInput == nil || len(modelInput.Messages) == 0 {
73+
return
74+
}
75+
r.runState.RecordModelInput(projectEinoMessagesToChat(modelInput.Messages))
76+
}
77+
78+
func (r *projectEinoAssistantModelCallbackRecorder) recordModelOutput(output callbacks.CallbackOutput) {
79+
modelOutput := einomodel.ConvCallbackOutput(output)
80+
if modelOutput == nil || modelOutput.Message == nil {
81+
return
82+
}
83+
reply := projectEinoAssistantReplyFromMessage(modelOutput.Message)
84+
if strings.TrimSpace(reply.Content) == "" && len(reply.ToolCalls) == 0 {
85+
return
86+
}
87+
r.runState.RecordAssistantReply(reply)
88+
}
89+
90+
func (r *projectEinoAssistantModelCallbackRecorder) recordModelStream(output *schema.StreamReader[callbacks.CallbackOutput]) {
91+
if output == nil {
92+
return
93+
}
94+
defer output.Close()
95+
96+
var content strings.Builder
97+
toolCalls := map[int]chatToolCall{}
98+
for {
99+
chunk, err := output.Recv()
100+
if errors.Is(err, io.EOF) {
101+
break
102+
}
103+
if err != nil {
104+
return
105+
}
106+
modelOutput := einomodel.ConvCallbackOutput(chunk)
107+
if modelOutput == nil || modelOutput.Message == nil {
108+
continue
109+
}
110+
msg := modelOutput.Message
111+
if msg.Content != "" {
112+
content.WriteString(msg.Content)
113+
if r.streamCallbacks.OnChunk != nil {
114+
r.streamCallbacks.OnChunk(msg.Content)
115+
}
116+
}
117+
if len(msg.ToolCalls) > 0 {
118+
r.reportToolPreparation()
119+
projectEinoMergeToolCalls(toolCalls, msg.ToolCalls)
120+
}
121+
}
122+
reply := projectAssistantReply{
123+
Content: content.String(),
124+
ToolCalls: projectEinoSortedToolCalls(toolCalls),
125+
}
126+
if strings.TrimSpace(reply.Content) == "" && len(reply.ToolCalls) == 0 {
127+
return
128+
}
129+
r.runState.RecordAssistantReply(reply)
130+
}
131+
132+
func (r *projectEinoAssistantModelCallbackRecorder) reportToolPreparation() {
133+
if r.streamCallbacks.OnStatus == nil {
134+
return
135+
}
136+
r.mu.Lock()
137+
defer r.mu.Unlock()
138+
if r.reportedToolPreparation {
139+
return
140+
}
141+
r.reportedToolPreparation = true
142+
r.streamCallbacks.OnStatus("Preparing action")
143+
}
144+
145+
func projectEinoAssistantReplyFromMessage(msg *schema.Message) projectAssistantReply {
146+
if msg == nil {
147+
return projectAssistantReply{}
148+
}
149+
return projectAssistantReply{
150+
Content: msg.Content,
151+
ToolCalls: projectEinoToolCallsToChat(msg.ToolCalls),
152+
}
153+
}
154+
155+
func projectEinoMergeToolCalls(out map[int]chatToolCall, toolCalls []schema.ToolCall) {
156+
for position, toolCall := range toolCalls {
157+
index := position
158+
if toolCall.Index != nil {
159+
index = *toolCall.Index
160+
}
161+
existing := out[index]
162+
if existing.ID == "" {
163+
existing.ID = toolCall.ID
164+
}
165+
if existing.Type == "" {
166+
existing.Type = projectEinoToolCallType(toolCall.Type)
167+
}
168+
if existing.Function.Name == "" {
169+
existing.Function.Name = toolCall.Function.Name
170+
}
171+
existing.Function.Arguments += toolCall.Function.Arguments
172+
if len(toolCall.Extra) > 0 {
173+
if existing.ExtraContent == nil {
174+
existing.ExtraContent = map[string]any{}
175+
}
176+
for key, value := range toolCall.Extra {
177+
existing.ExtraContent[key] = value
178+
}
179+
}
180+
out[index] = existing
181+
}
182+
}
183+
184+
func projectEinoSortedToolCalls(toolCalls map[int]chatToolCall) []chatToolCall {
185+
if len(toolCalls) == 0 {
186+
return nil
187+
}
188+
indices := make([]int, 0, len(toolCalls))
189+
for index := range toolCalls {
190+
indices = append(indices, index)
191+
}
192+
sort.Ints(indices)
193+
out := make([]chatToolCall, 0, len(toolCalls))
194+
for _, index := range indices {
195+
out = append(out, toolCalls[index])
196+
}
197+
return out
198+
}

0 commit comments

Comments
 (0)